7 Commits
Author SHA1 Message Date
simonfrankenandClaude Sonnet 5.5 f9838c0c99 Fix flaky TestWatch when a fetch is reported in two rounds
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:24:11 +02:00
simonfrankenandClaude Sonnet 5.5 651ab10f51 Use sub as the default command in the README
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:18:34 +02:00
simonfrankenandClaude Sonnet 5.5 e0f4ac823b Show staged pins in the tree
A staged pin was only visible in the detail pane, so the header's
"n pin staged" could not be traced to a row. The status column now shows
a blue "staged" instead of "in sync", a blue marker after an issue word,
and the marker on collapsed rows with a staged pin below. It is still not an
issue and not counted in +n.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 10:14:35 +02:00
simonfrankenandClaude Sonnet 5.5 83824e42c1 Add a README with a screenshot and examples
Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:28:49 +02:00
simonfrankenandClaude Sonnet 5.5 f5fabfd093 Add / filter for the tree
A text input above the footer filters submodules by path (case-insensitive).
Matches keep their ancestors. A filtered-out selection moves to the next
visible submodule; esc closes the input and removes the filter.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 21:15:24 +02:00
simonfrankenandClaude Sonnet 5.5 8aa12774eb Resolve pins to the closest branch
A pin on a pushed feature branch showed as diverged from origin/main
because the .gitmodules branch won before any distance was compared.

Now the branch containing the pin with the fewest commits after it wins;
the .gitmodules branch, the upstream and the remote default branch only
break ties and are always compared. An unpushed pin compares just those
preferred refs by commits on either side.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:57:55 +02:00
simonfrankenandClaude Sonnet 5.5 5cb809428b Add c to copy the selected path, split key bar into groups
c copies the absolute directory of the selected submodule via OSC 52.
Keys are grouped into navigation (footer left), global (footer right)
and submodule (detail pane palette, footer if the pane can't show it).
Adds a key reference table to the UX guide.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-21 20:48:16 +02:00
29 changed files with 934 additions and 125 deletions
+135
View File
@@ -0,0 +1,135 @@
# submarine
**A terminal UI for git repositories with many submodules.**
See what needs your attention. Ignore the rest.
`git submodule status` gives you a wall of commit hashes. submarine answers
the questions you actually have:
- Which branch is this submodule on, and is the pin current?
- Which submodules need me, and which are fine?
- Is anything dangerous, like a pin nobody else can fetch?
![submarine showing a tree with nested submodules and the detail pane of an off-pin submodule that is one commit behind its branch](docs/images/overview.png)
## Why you might like it
- **Only submodules, as a tree.** Nested submodules at any depth, grouped by
directory, collapsed until you open them.
- **Branch plus distance, not hashes.** `origin/main ↓2` says more than
`b7c8d9e`. A pin on a feature branch shows that branch.
- **Problems are loud, healthy is quiet.** Submodules without issues are
muted `in sync`; a staged pin shows as blue `staged` (or a `●` after an
issue word). A collapsed parent counts the problems hidden below it
(`·1`) and shows `●` if a staged pin is hidden.
- **Words, not symbols.** Every state is a short word (`unpushed`, `conflict`,
`missing`, `off pin`, `changed`), so nothing depends on color alone.
- **Live.** Fetches in the background on startup and follows changes you make
in other shells within about two seconds.
- **Built for dozens to hundreds of submodules.** Each status loads
asynchronously, so the UI never freezes.
## Install
```sh
curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh
```
Installs `sub` and `submarine` (linux/macOS, amd64/arm64) to `~/.local/bin`.
Both names start the same program; the examples below use the shorter `sub`.
Update later with `sub update`.
## Try it
```sh
sub # the repository in the current directory
sub ~/code/project # any directory inside a repository
sub --no-fetch # skip the background fetch
sub --mock # built-in sample data, no repository needed
```
`submarine` works everywhere `sub` does, e.g. `submarine --mock`.
## Examples
### Find what needs attention
Problems stand out: `unpushed` in red, `off pin` and `changed` in yellow,
everything else muted. Select a submodule to see the details on the right:
every issue in words, the resolved ref and why, the pinned, checked-out and
tip commits, and a preview of uncommitted changes.
```
src/ │ src/
core off pin origin/main │ net
▸ net [2] unpushed origin/main ↑1│
ui in sync origin/main │ unpushed pin not pushed to any remote
│
│ Ref origin/main ↑1
│ via checked out branch
│ Pinned b7c8d9e Hotfix null pointer
│ Checkout b7c8d9e Hotfix null pointer
│ Tip 6f5e4d3 Release 2.1.0
```
### Jump to a submodule with `/`
Press `/` and type. Only matching submodules (and the parents that lead to
them) stay. Your selection moves to the next match if it was filtered out.
```
libs/
▾ log in sync origin/main
modules/
nested changed origin/main
/ nest enter navigate • esc close
```
`enter` keeps the filter and gives you the normal keys back, `esc` removes
it and leaves your selection where it is.
### Copy a path
Select a submodule and press `c`. Its absolute directory lands in your
clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
`set -g set-clipboard on`).
### Fetch and reload
`f` fetches all repositories again, `r` reloads everything after you edited
files.
## Keys
| Key | Action |
|---|---|
| `↑` `↓` / `j` `k` | Move |
| `←` `→` / `h` `l` | Fold / unfold, parent / first child |
| `H` `L` / `⇧←` `⇧→` | Collapse / expand all |
| `space` | Toggle the selected submodule |
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
| `/` | Filter |
| `c` | Copy path |
| `f` / `r` | Fetch / reload |
| `?` | Full help |
| `q` | Quit |
The mouse works too: click to select, click `▸` to toggle, wheel to move.
## Status
A prototype. Browsing is complete; the actions `u` (check out the pin),
`b` (bump the pin to the tip) and `!` (problems only) are planned.
## Documentation
Requirements, state rules, UX guide, architecture and the decision log are in
[docs/](docs/README.md).
## Development
```sh
go run ./cmd/submarine --mock # run on mock data
scripts/make-fixture.sh DIR # build a test superproject with every state
go vet ./... && go test ./...
```
+2 -2
View File
@@ -32,9 +32,9 @@ through a git provider (`internal/submodule/git`): `submarine [dir]`. It
fetches in the background on startup (`--no-fetch` to skip; `f` to fetch
again) and follows changes made in other shells. `submarine --mock` shows
the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`,
`/`, `!` only show a "not implemented" message.
`!` only show a "not implemented" message.
Next steps, roughly in order:
1. Actions: checkout pin (`submodule update`), bump pin to tip, init.
2. Filter (`/`) and "problems only" (`!`) views.
2. "Problems only" (`!`) view; it can reuse the tree's filter.
+7 -1
View File
@@ -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
+59 -1
View File
@@ -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.
@@ -200,3 +201,60 @@ 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.
## Filter: matches with ancestors, lives as long as the input
`/` filters by case-insensitive substring of the submodule path. Matches
keep their ancestors, shown expanded, because a nested submodule alone
would lose its context; the user's fold state is not touched, so closing the
filter restores it (plus the selection's ancestors, so the selection stays
visible). The filter exists exactly while the input line is open, and `esc`
closes both, so there is no hidden filter that makes the list look
incomplete. While typing, `j`/`k`/`q` are text; `enter` returns the keys to
the tree and keeps the filter, so a filtered list can be navigated with the
usual keys. A filtered-out selection moves to the next visible submodule
(previous if none follows) instead of the first, so the cursor stays near
where it was. This adds the `bubbles/textinput` dependency (and its
`atotto/clipboard` import).
## Staged pin is shown in the tree, but is not an issue
A staged pin was only visible in the detail pane, so the header's `1 pin
staged` could not be traced to a row, and `in sync nothing to do` was
wrong for a submodule whose pin change still has to be committed. The tree
now shows it in the status column: blue `staged` replaces `in sync`; next to
an issue word it is a blue `●` (an issue wins the word, since the cell holds
one word, D3). It is not counted in `+n`, because `+n` and the header count
issues and a staged pin stays quiet (D4). A collapsed row shows `●` if a
staged pin is hidden below it (`Hidden.Staged`), placed before the `·k`
counts so truncation of the 16-column cell cuts the counts first.
Binary file not shown.

After

Width:  |  Height:  |  Size: 127 KiB

+9 -3
View File
@@ -35,7 +35,7 @@ commit hashes. It doesn't answer the questions users actually have:
e.g. `[2]`.
- **F6** A collapsed submodule shows the number of issues **hidden** in all
its descendants (`·1 ·2`), one count per level: errors in red, then
warnings in yellow.
warnings in yellow. A staged pin below shows as a blue `●` before them.
- **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).
@@ -44,7 +44,8 @@ commit hashes. It doesn't answer the questions users actually have:
- **F10** Each submodule shows at most **one** state word: the most severe
issue, plus `+n` if there are more. Submodules without issues show a
muted `in sync`.
muted `in sync`, or a blue `staged` if their pin change is staged. With
issues, a staged pin adds a blue `●` after the word.
See [state-model.md](state-model.md) for the full rules.
- **F11** Each submodule shows its pin as a ref (`origin/main`, a tag, or a
short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`).
@@ -103,13 +104,18 @@ commit hashes. It doesn't answer the questions users actually have:
- **F53** `f` fetches again; `r` reloads everything (e.g. after editing
files, which is not watched).
- **F54** `/` filters the tree by name/path (case-insensitive substring).
A text input above the footer takes the term; `esc` closes it and removes
the filter. A selection that is filtered out moves to the next visible
submodule (none if nothing matches); closing the filter keeps the
selection. See [Filter](ux-guide.md#filter).
### Actions (planned, not implemented)
| Key | Action |
|---|---|
| `u` | Check out the pinned commit (`git submodule update`) |
| `b` | Bump the pin to the tip of its ref |
| `/` | Filter by name/path |
| `!` | Show only submodules with issues |
## Usage
+30 -14
View File
@@ -82,6 +82,8 @@ Rules:
changed.
- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed,
staged pin, detached HEAD on the pin. They are shown as information only.
A staged pin is also visible in the tree (`staged` or a `●` marker), see
[ux-guide.md](ux-guide.md#tree-row), but is never counted as an issue.
- A loading error is shown as `error` (Danger) in the UI; it is not an issue
kind.
@@ -104,17 +106,30 @@ 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).
@@ -143,12 +158,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
+69 -5
View File
@@ -34,8 +34,24 @@
| Header | 1 line |
| Tree | Remaining width |
| Detail pane | 40% of the width, at least 36 columns; hidden below 90 columns |
| Filter line | 1 line above the footer, only while filtering |
| 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 +63,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 | 16 | issue word, `+n`, `·k` hidden issues per level (red, then yellow); muted `in sync` without issues; spinner while loading |
| status | 16 | issue word, `+n`, `●` staged pin, `·k` hidden issues per level (red, then yellow); blue `staged` or 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` |
@@ -61,8 +77,8 @@ indent ▸ name [n] status ref delta
1. Directory (muted) and name (bold)
2. One line per issue: word (colored, 10 columns) + explanation;
`in sync nothing to do` if there are none; `staged` and
`detached HEAD on pin` as information
`in sync nothing to do` if there are none and no pin is staged;
`staged` and `detached HEAD on pin` as information
3. `Changes` (only with uncommitted changes), see
[Change preview](#change-preview)
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
@@ -149,7 +165,7 @@ colors in components.
|---|---|---|
| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin |
| Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA |
| Info | ANSI `4` blue | `staged` |
| Info | ANSI `4` blue | `staged`, the `●` staged marker |
| Primary | ANSI `6` cyan | branch names, commit hashes, spinner |
| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels |
| Text | terminal default | normal text |
@@ -191,7 +207,9 @@ colors in components.
| `q`, `ctrl+c` | Quit |
| `f` | Fetch all repositories (does nothing while a fetch runs) |
| `r` | Reload everything (e.g. after editing files) |
| `u`, `b`, `/`, `!` | Planned actions (see requirements) |
| `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 |
| `/` | Open the filter, see [Filter](#filter) |
| `u`, `b`, `!` | Planned actions (see requirements) |
"First child" always means the first child **as displayed** (children are
grouped by directory).
@@ -200,6 +218,52 @@ 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.
### Filter
`/` shows a text input above the footer (`/ term`). While it is open the
tree shows only the submodules whose path (which includes the name)
contains the term, ignoring case; their ancestors stay as context and are
shown expanded, whatever the user folded. Directory headers without a
match vanish. If nothing matches the tree says `no submodule matches`.
- **Selection.** If the selected submodule is filtered out, the next
visible one (in display order) is selected, else the previous one; if none
is left, nothing is selected (the detail pane is empty).
- **Typing.** The input has the focus: letters edit the term, so `j`, `q`
etc. are text. `↑` `↓` `pgup` `pgdn` still move the selection. `enter`
hands the keys back to the tree (the filter stays, all keys work again);
`/` edits the term again.
- **`esc`** closes the input and removes the filter, in both modes. The
selection stays; its ancestors are expanded so it is still visible.
- The filter line shows the keys that apply on its right.
- The header counts are not filtered.
### 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 | yes |
| `!` | 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.
+2 -1
View File
@@ -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,7 @@ require (
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/atotto/clipboard v0.1.4 // 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
+2
View File
@@ -1,3 +1,5 @@
github.com/atotto/clipboard v0.1.4 h1:EH0zSVneZPSuFR11BlR9YppQTVDbh5+16AmcJi4g1z4=
github.com/atotto/clipboard v0.1.4/go.mod h1:ZY9tmq7sm5xIbd9bOK4onWV4S6X0u6GY7Vn0Yu86PYI=
github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
+11
View File
@@ -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 == "" {
+7 -1
View File
@@ -139,7 +139,13 @@ func TestWatch(t *testing.T) {
// a commit in another shell
git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local")
if c := next(t, ch); c != (submodule.Change{Path: "libs/store", Local: true}) {
c := next(t, ch)
if c == (submodule.Change{Path: "src/net"}) {
// git fetch writes FETCH_HEAD and the refs at different moments, so
// with a 20ms poll it can be reported in two rounds
c = next(t, ch)
}
if c != (submodule.Change{Path: "libs/store", Local: true}) {
t.Errorf("after commit: %+v, want local change of libs/store", c)
}
+99 -49
View File
@@ -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.
@@ -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")
+2 -1
View File
@@ -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}},
+7
View File
@@ -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
+8 -4
View File
@@ -60,13 +60,14 @@ func (s *Status) Issues() []Issue {
return out
}
// Hidden counts issues below a submodule per level.
type Hidden struct{ Warn, Danger int }
// Hidden counts issues below a submodule per level, plus staged pins (which
// are not issues).
type Hidden struct{ Warn, Danger, Staged int }
// Total is the number of issues of all levels.
// Total is the number of issues of all levels; staged pins are not counted.
func (h Hidden) Total() int { return h.Warn + h.Danger }
// HiddenIssues counts the issues of all loaded descendants per level. A
// HiddenIssues counts the issues and staged pins of all loaded descendants. A
// descendant whose load failed counts as one danger, since its state is
// unknown.
func (m *Submodule) HiddenIssues() Hidden {
@@ -75,6 +76,9 @@ func (m *Submodule) HiddenIssues() Hidden {
if c.Status == nil && c.Err != nil {
h.Danger++
}
if c.Status != nil && c.Status.PinStaged {
h.Staged++
}
for _, is := range c.Status.Issues() {
switch is.Level {
case LevelDanger:
+3 -3
View File
@@ -39,15 +39,15 @@ func TestIssues(t *testing.T) {
func TestHiddenIssues(t *testing.T) {
ok := &Status{Pin: PinInfo{Pushed: true}}
dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}}
dirty := &Status{Worktree: WorktreeModified, Pin: PinInfo{Pushed: true}, PinStaged: true}
unpushed := &Status{}
grandchild := &Submodule{Status: unpushed}
child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}}
loading := &Submodule{}
root := &Submodule{Status: ok, Children: []*Submodule{child, {Status: ok}, loading}}
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1}) {
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger", got)
if got := root.HiddenIssues(); got != (Hidden{Warn: 1, Danger: 1, Staged: 1}) {
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger, 1 staged", got)
}
}
+106 -8
View File
@@ -4,10 +4,12 @@ package tui
import (
"context"
"strings"
"time"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/bubbles/spinner"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
zone "github.com/lrstanley/bubblezone"
@@ -55,6 +57,10 @@ type Model struct {
detail detail.Model
footer footer.Model
spinner spinner.Model
filter textinput.Model
// filtering is true while the filter line is shown; the filter applies
// exactly then. The input is focused while typing and blurred after enter.
filtering bool
width, height int
showDetail bool
@@ -64,6 +70,12 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
t := theme.Default()
k := defaultKeys()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
fi := textinput.New()
fi.Prompt = "/ "
fi.PromptStyle = theme.Fg(t.Primary)
fi.Cursor.Style = theme.Fg(t.Primary)
fi.Placeholder = "filter by name or path"
fi.PlaceholderStyle = theme.Fg(t.Muted)
m := Model{
ctx: ctx,
provider: p,
@@ -73,9 +85,10 @@ 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,
filter: fi,
}
m.fetcher, _ = p.(submodule.Fetcher)
m.watcher, _ = p.(submodule.Watcher)
@@ -309,7 +322,12 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
k := m.keys
if m.filter.Focused() {
return m.typeFilter(msg)
}
switch {
case m.filtering && msg.Type == tea.KeyEsc:
m.closeFilter()
case key.Matches(msg, k.Quit):
return m, tea.Quit
case key.Matches(msg, k.Help):
@@ -353,7 +371,13 @@ 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.Update, k.Bump, k.Filter, k.Issues):
case key.Matches(msg, k.Copy):
return m.copyPath()
case key.Matches(msg, k.Filter):
m.filtering = true
m.layout()
return m, m.filter.Focus()
case key.Matches(msg, k.Update, k.Bump, k.Issues):
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
m.layout()
return m, cmd
@@ -361,10 +385,83 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
}
// typeFilter handles a key while the filter input has the focus: text edits
// the filter, enter hands the keys back to the tree (the filter stays), esc
// closes the filter. Arrows still move the selection.
func (m Model) typeFilter(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyCtrlC:
return m, tea.Quit
case tea.KeyEsc:
m.closeFilter()
case tea.KeyEnter:
m.filter.Blur()
case tea.KeyUp:
m.tree.Up()
case tea.KeyDown:
m.tree.Down()
case tea.KeyPgUp:
m.tree.PageUp()
case tea.KeyPgDown:
m.tree.PageDown()
default:
var cmd tea.Cmd
m.filter, cmd = m.filter.Update(msg)
m.tree.SetFilter(m.filter.Value())
return m, cmd
}
return m, nil
}
// closeFilter hides the filter line and removes the filter; the selection stays.
func (m *Model) closeFilter() {
m.filtering = false
m.filter.Blur()
m.filter.Reset()
m.tree.SetFilter("")
m.layout()
}
// filterView is the line above the footer: the input and, right-aligned, the
// keys that apply.
func (m Model) filterView() string {
hint := "enter navigate • esc close"
if !m.filter.Focused() {
hint = "/ edit • esc close"
}
hint = theme.Fg(m.theme.Muted).Render(hint)
m.filter.Width = max(m.width-lipgloss.Width(hint)-6, 1)
in := m.filter.View()
gap := max(m.width-2-lipgloss.Width(in)-lipgloss.Width(hint), 1)
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
}
// 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)
bodyHeight := max(m.height-headerHeight-lipgloss.Height(m.footer.View()), 0)
bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View())
if m.filtering {
bodyHeight--
}
bodyHeight = max(bodyHeight, 0)
m.showDetail = m.width >= detailHideBelow
treeWidth := m.width
@@ -373,6 +470,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)
}
@@ -394,9 +492,9 @@ func (m Model) View() string {
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
}
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
body,
m.footer.View(),
))
parts := []string{header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width), body}
if m.filtering {
parts = append(parts, m.filterView())
}
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
}
+21
View File
@@ -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
}
}
+33 -6
View File
@@ -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 {
@@ -73,11 +97,11 @@ func (m Model) lines(s *submodule.Submodule) []string {
for _, is := range issues {
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
}
if len(issues) == 0 {
if len(issues) == 0 && !st.PinStaged {
out = append(out, label(theme.InSync)+muted.Render("nothing to do"))
}
if st.PinStaged {
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render("staged")+text.Render("pin change staged, not committed"))
out = append(out, theme.Fg(t.Info).Width(labelWidth).Render(theme.Staged)+text.Render("pin change staged, not committed"))
}
if st.Checkout == submodule.CheckoutDetached {
out = append(out, label("")+muted.Render("detached HEAD on pin"))
@@ -146,6 +170,9 @@ func (m Model) lines(s *submodule.Submodule) []string {
if h.Warn > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelWarn, lipgloss.NewStyle()).Render(fmt.Sprintf("%d warning(s)", h.Warn)))
}
if h.Staged > 0 {
parts = append(parts, theme.Fg(t.Info).Render(fmt.Sprintf("%d %s", h.Staged, theme.Staged)))
}
if len(parts) > 0 {
children += muted.Render(" · ") + strings.Join(parts, muted.Render(", ")) + muted.Render(" below")
}
+46 -11
View File
@@ -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
}
}
}
+18 -4
View File
@@ -7,7 +7,7 @@ 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 {
@@ -29,16 +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")),
}
}
func (k keyMap) ShortHelp() []key.Binding {
// 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, k.Help, k.Quit}
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 append(append(k.Navigation(), k.Submodule()...), k.Global()...)
}
func (k keyMap) FullHelp() [][]key.Binding {
@@ -46,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},
}
}
+15
View File
@@ -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
}
+4
View File
@@ -12,6 +12,10 @@ import (
// InSync is the word for a submodule without issues.
const InSync = "in sync"
// Staged is the word for a pin change staged in the superproject. It is not
// an issue: it replaces InSync, or follows an issue word as a marker.
const Staged = "staged"
// IssueWord is the short label for an issue kind shown in the tree.
func IssueWord(k submodule.IssueKind) string {
switch k {
+141
View File
@@ -0,0 +1,141 @@
package tree
import (
"strings"
"submarine/internal/submodule"
)
// SetFilter shows only the submodules whose path contains term (ignoring
// case), plus their ancestors, which are shown expanded. An empty term
// removes the filter.
//
// If the selected submodule is filtered out, the next visible one is
// selected (the previous one if there is none after it, none if nothing
// matches). Removing the filter keeps the selection visible by expanding its
// ancestors.
func (m *Model) SetFilter(term string) {
term = strings.ToLower(term)
if term == m.filter {
return
}
old := m.cursor
m.filter = term
if term == "" {
for p := parentOf(old); p != nil; p = p.Parent {
m.expanded[p.Path] = true
}
if m.cursor == nil {
m.cursor = m.firstNode()
}
} else {
m.reselect(old)
}
m.clampOffset()
}
// Filter returns the active filter term in lower case.
func (m Model) Filter() string { return m.filter }
func parentOf(s *submodule.Submodule) *submodule.Submodule {
if s == nil {
return nil
}
return s.Parent
}
// reselect makes sure the cursor is on a visible submodule. If old is not
// visible any more, the closest visible one after it in display order
// becomes the selection, else the closest one before it.
func (m *Model) reselect(old *submodule.Submodule) {
visible := map[*submodule.Submodule]bool{}
var first *submodule.Submodule
for _, r := range m.rows() {
if r.kind == rowNode {
visible[r.node] = true
if first == nil {
first = r.node
}
}
}
if old != nil && visible[old] {
m.cursor = old
return
}
m.cursor = first
if old == nil || first == nil {
return
}
var all []row
m.appendAll(&all, m.roots, 0)
i := 0
for ; i < len(all); i++ {
if all[i].node == old {
break
}
}
for j := i + 1; j < len(all); j++ {
if visible[all[j].node] {
m.cursor = all[j].node
return
}
}
for j := i - 1; j >= 0; j-- {
if visible[all[j].node] {
m.cursor = all[j].node
return
}
}
}
// appendAll lists every submodule in display order, whatever is expanded or
// filtered.
func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
for _, g := range groupByDir(mods) {
for _, s := range g.mods {
*out = append(*out, row{kind: rowNode, depth: depth, node: s})
m.appendAll(out, s.Children, depth+1)
}
}
}
// isExpanded reports whether the children of s are shown. While filtering,
// that is the case if a match is below s, whatever the user expanded.
func (m *Model) isExpanded(s *submodule.Submodule) bool {
if m.filter == "" {
return m.expanded[s.Path]
}
for _, c := range s.Children {
if m.shown(c) {
return true
}
}
return false
}
// filtered returns the submodules of mods that pass the filter.
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
if m.filter == "" {
return mods
}
var out []*submodule.Submodule
for _, s := range mods {
if m.shown(s) {
out = append(out, s)
}
}
return out
}
// shown reports whether s matches the filter or has a descendant that does.
func (m *Model) shown(s *submodule.Submodule) bool {
if strings.Contains(strings.ToLower(s.Path), m.filter) {
return true
}
for _, c := range s.Children {
if m.shown(c) {
return true
}
}
return false
}
+2 -2
View File
@@ -30,7 +30,7 @@ func (m *Model) rows() []row {
// appendRows groups mods by the directory of their RelPath. Each group with a
// directory gets a header row; its submodules are indented one level deeper.
func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
for _, g := range groupByDir(mods) {
for _, g := range groupByDir(m.filtered(mods)) {
d := depth
if g.dir != "." {
*out = append(*out, row{kind: rowDir, depth: depth, label: g.dir + "/"})
@@ -38,7 +38,7 @@ func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
}
for _, s := range g.mods {
*out = append(*out, row{kind: rowNode, depth: d, node: s})
if m.expanded[s.Path] && len(s.Children) > 0 {
if m.isExpanded(s) && len(s.Children) > 0 {
m.appendRows(out, s.Children, d+1)
}
}
+30 -9
View File
@@ -25,6 +25,7 @@ type Model struct {
theme theme.Theme
roots []*submodule.Submodule
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
filter string // lower-case; empty if not filtering
cursor *submodule.Submodule
offset int // index of the first visible row
width int
@@ -42,9 +43,7 @@ func (m *Model) SetRoots(roots []*submodule.Submodule) {
m.expanded = map[string]bool{}
m.cursor = nil
m.offset = 0
if len(roots) > 0 {
m.cursor = m.rows()[m.firstNodeRow()].node
}
m.cursor = m.firstNode()
}
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
@@ -63,8 +62,8 @@ func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
break
}
}
if m.cursor == nil && len(roots) > 0 {
m.cursor = m.rows()[m.firstNodeRow()].node
if m.cursor == nil {
m.reselect(nil)
}
m.clampOffset()
}
@@ -210,6 +209,15 @@ func (m Model) cursorRow(rows []row) int {
return -1
}
func (m *Model) firstNode() *submodule.Submodule {
for _, r := range m.rows() {
if r.kind == rowNode {
return r.node
}
}
return nil
}
func (m Model) firstNodeRow() int {
for i, r := range m.rows() {
if r.kind == rowNode {
@@ -304,6 +312,9 @@ func (m Model) View() string {
lines = append(lines, m.nodeLine(r, nameColW, refW))
}
}
if len(rows) == 0 && m.filter != "" {
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(" no submodule matches"))
}
for len(lines) < m.height {
lines = append(lines, strings.Repeat(" ", m.width))
}
@@ -324,7 +335,7 @@ func (m Model) nameColumnWidth(rows []row, refW int) int {
}
func (m Model) childCount(s *submodule.Submodule) string {
if len(s.Children) == 0 || m.expanded[s.Path] {
if len(s.Children) == 0 || m.isExpanded(s) {
return ""
}
return fmt.Sprintf(" [%d]", len(s.Children))
@@ -368,7 +379,7 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
}
pad := func(n int) string { return base.Render(strings.Repeat(" ", max(n, 0))) }
collapsed := len(s.Children) > 0 && !m.expanded[s.Path]
collapsed := len(s.Children) > 0 && !m.isExpanded(s)
// toggle marker
marker := " "
@@ -399,7 +410,12 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
nameCell := seg(textColor, name) + seg(t.Muted, count) + pad(nameW-lipgloss.Width(name+count))
// status: the most severe issue as a word, "+n" for further issues;
// a muted "in sync" without issues, so an empty cell never looks broken
// a muted "in sync" without issues, so an empty cell never looks broken.
// A staged pin is no issue: it replaces "in sync", or follows the issue
// as a blue marker (also for staged pins below a collapsed row).
staged := s.Status != nil && s.Status.PinStaged
h := s.HiddenIssues()
stagedMark := staged && len(issues) > 0
var status string
switch {
case s.Err != nil:
@@ -411,10 +427,15 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
if len(issues) > 1 {
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
}
case staged:
status = seg(t.Info, theme.Staged)
default:
status = seg(t.Muted, theme.InSync)
}
if h := s.HiddenIssues(); collapsed && h.Total() > 0 {
if stagedMark || collapsed && h.Staged > 0 {
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
}
if collapsed && h.Total() > 0 {
for _, c := range []struct {
level submodule.Level
n int
+56
View File
@@ -249,3 +249,59 @@ func TestExpandAllOnLeafKeepsSelection(t *testing.T) {
t.Fatal("all nodes with children should be expanded")
}
}
func TestFilterShowsMatchesWithAncestors(t *testing.T) {
m := newTree(t)
m.SetFilter("D")
want := []string{"src/", "b", "modules/", "c", "d"}
if got := visible(m); !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
assertCursor(t, m, "b") // a was selected and is gone: next available (an ancestor of the match)
}
func TestFilterSelectionMovesToNextThenPrevious(t *testing.T) {
m := newTree(t)
m.Select("a")
m.SetFilter("f")
assertCursor(t, m, "f")
m.SetFilter("")
m.Select("f")
m.SetFilter("a") // only a matches, it lies before f
assertCursor(t, m, "a")
}
func TestFilterWithoutMatchSelectsNothing(t *testing.T) {
m := newTree(t)
m.SetFilter("zzz")
if m.Selected() != nil || len(m.rows()) != 0 {
t.Fatalf("selected %v, rows %v", m.Selected(), visible(m))
}
m.Down() // must not panic
m.SetFilter("f")
assertCursor(t, m, "f")
}
func TestClearingFilterKeepsSelectionVisible(t *testing.T) {
m := newTree(t)
m.SetFilter("d")
m.Select("d")
m.SetFilter("")
assertCursor(t, m, "d")
want := []string{"a", "src/", "b", "modules/", "c", "d", "e", "f"}
if got := visible(m); !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
}
func equal(a, b []string) bool {
if len(a) != len(b) {
return false
}
for i := range a {
if a[i] != b[i] {
return false
}
}
return true
}
+10
View File
@@ -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