Archived
Compare commits
@@ -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?
|
||||||
|
|
||||||
|

|
||||||
|
|
||||||
|
## 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
@@ -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
|
fetches in the background on startup (`--no-fetch` to skip; `f` to fetch
|
||||||
again) and follows changes made in other shells. `submarine --mock` shows
|
again) and follows changes made in other shells. `submarine --mock` shows
|
||||||
the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`,
|
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:
|
Next steps, roughly in order:
|
||||||
|
|
||||||
1. Actions: checkout pin (`submodule update`), bump pin to tip, init.
|
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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
+65
-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,60 @@ 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.
|
||||||
|
|
||||||
|
## 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 |
+10
-3
@@ -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. A staged pin below shows as a blue `●` before them.
|
||||||
- **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).
|
||||||
@@ -43,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
|
- **F10** Each submodule shows at most **one** state word: the most severe
|
||||||
issue, plus `+n` if there are more. Submodules without issues show a
|
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.
|
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
|
- **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`).
|
short SHA as a fallback) and the distance to that ref (`↑n`, `↓n`, `↑n↓m`).
|
||||||
@@ -102,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
|
- **F53** `f` fetches again; `r` reloads everything (e.g. after editing
|
||||||
files, which is not watched).
|
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)
|
### Actions (planned, not implemented)
|
||||||
|
|
||||||
| Key | Action |
|
| Key | Action |
|
||||||
|---|---|
|
|---|---|
|
||||||
| `u` | Check out the pinned commit (`git submodule update`) |
|
| `u` | Check out the pinned commit (`git submodule update`) |
|
||||||
| `b` | Bump the pin to the tip of its ref |
|
| `b` | Bump the pin to the tip of its ref |
|
||||||
| `/` | Filter by name/path |
|
|
||||||
| `!` | Show only submodules with issues |
|
| `!` | Show only submodules with issues |
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|||||||
+35
-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:
|
||||||
|
|
||||||
@@ -82,6 +82,8 @@ Rules:
|
|||||||
changed.
|
changed.
|
||||||
- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed,
|
- **Not issues** (quiet): in sync, pin behind/ahead/diverged but pushed,
|
||||||
staged pin, detached HEAD on the pin. They are shown as information only.
|
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
|
- A loading error is shown as `error` (Danger) in the UI; it is not an issue
|
||||||
kind.
|
kind.
|
||||||
|
|
||||||
@@ -96,23 +98,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 +158,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
|
||||||
|
|||||||
+82
-13
@@ -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 |
|
||||||
@@ -34,8 +34,24 @@
|
|||||||
| Header | 1 line |
|
| Header | 1 line |
|
||||||
| Tree | Remaining width |
|
| Tree | Remaining width |
|
||||||
| 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 |
|
||||||
|
| Filter line | 1 line above the footer, only while filtering |
|
||||||
| 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 +63,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`, `●` 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 |
|
| ref | widest ref, max 22 | resolved ref; muted short SHA if unresolved |
|
||||||
| delta | 6 | `↑n`, `↓n`, `↑n↓m` |
|
| delta | 6 | `↑n`, `↓n`, `↑n↓m` |
|
||||||
|
|
||||||
@@ -61,8 +77,8 @@ indent ▸ name [n] status ref delta
|
|||||||
|
|
||||||
1. Directory (muted) and name (bold)
|
1. Directory (muted) and name (bold)
|
||||||
2. One line per issue: word (colored, 10 columns) + explanation;
|
2. One line per issue: word (colored, 10 columns) + explanation;
|
||||||
`in sync nothing to do` if there are none; `staged` and
|
`in sync nothing to do` if there are none and no pin is staged;
|
||||||
`detached HEAD on pin` as information
|
`staged` and `detached HEAD on pin` as information
|
||||||
3. `Changes` (only with uncommitted changes), see
|
3. `Changes` (only with uncommitted changes), see
|
||||||
[Change preview](#change-preview)
|
[Change preview](#change-preview)
|
||||||
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
|
4. `Ref` with distance and `via <rule>`; `Fetched <age>` of the submodule;
|
||||||
@@ -91,7 +107,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 +136,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:
|
||||||
@@ -145,7 +165,7 @@ colors in components.
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin |
|
| Danger | ANSI `1` red, bold | `unpushed`, `conflict`, `error`, `↑n` of an unpushed pin |
|
||||||
| Warn | ANSI `3` yellow | `missing`, `off pin`, `changed`, differing checkout SHA |
|
| 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 |
|
| Primary | ANSI `6` cyan | branch names, commit hashes, spinner |
|
||||||
| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels |
|
| Muted | ANSI `8` grey | quiet rows, directory headers, arrows, labels |
|
||||||
| Text | terminal default | normal text |
|
| Text | terminal default | normal text |
|
||||||
@@ -179,15 +199,17 @@ 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) |
|
||||||
| `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
|
"First child" always means the first child **as displayed** (children are
|
||||||
grouped by directory).
|
grouped by directory).
|
||||||
@@ -196,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
|
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.
|
||||||
|
|
||||||
|
### 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
|
### Mouse
|
||||||
|
|
||||||
- Click a row: select it.
|
- Click a row: select it.
|
||||||
@@ -205,5 +273,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,7 @@ require (
|
|||||||
)
|
)
|
||||||
|
|
||||||
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/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
|
||||||
|
|||||||
@@ -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 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k=
|
||||||
github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8=
|
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=
|
github.com/aymanbagabas/go-udiff v0.3.1 h1:LV+qyBQ2pqe0u42ZsUEtPiCaUoqgA9gYRDs3vj1nolY=
|
||||||
|
|||||||
@@ -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 == "" {
|
||||||
|
|||||||
@@ -139,7 +139,13 @@ func TestWatch(t *testing.T) {
|
|||||||
|
|
||||||
// a commit in another shell
|
// a commit in another shell
|
||||||
git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local")
|
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)
|
t.Errorf("after commit: %+v, want local change of libs/store", c)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,40 @@ 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, plus staged pins (which
|
||||||
// most severe level among them.
|
// are not issues).
|
||||||
func (m *Submodule) HiddenIssues() (count int, worst Level) {
|
type Hidden struct{ Warn, Danger, Staged int }
|
||||||
|
|
||||||
|
// 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 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 {
|
||||||
|
var h Hidden
|
||||||
Walk(m.Children, func(c *Submodule) {
|
Walk(m.Children, func(c *Submodule) {
|
||||||
|
if c.Status == nil && c.Err != nil {
|
||||||
|
h.Danger++
|
||||||
|
}
|
||||||
|
if c.Status != nil && c.Status.PinStaged {
|
||||||
|
h.Staged++
|
||||||
|
}
|
||||||
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 +98,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"
|
||||||
)
|
)
|
||||||
@@ -38,15 +39,42 @@ func TestIssues(t *testing.T) {
|
|||||||
|
|
||||||
func TestHiddenIssues(t *testing.T) {
|
func TestHiddenIssues(t *testing.T) {
|
||||||
ok := &Status{Pin: PinInfo{Pushed: true}}
|
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{}
|
unpushed := &Status{}
|
||||||
grandchild := &Submodule{Status: unpushed}
|
grandchild := &Submodule{Status: unpushed}
|
||||||
child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}}
|
child := &Submodule{Status: dirty, Children: []*Submodule{grandchild}}
|
||||||
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, Staged: 1}) {
|
||||||
if count != 2 || worst != LevelDanger {
|
t.Errorf("HiddenIssues() = %+v; want 1 warn, 1 danger, 1 staged", 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+106
-8
@@ -4,10 +4,12 @@ package tui
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/charmbracelet/bubbles/key"
|
"github.com/charmbracelet/bubbles/key"
|
||||||
"github.com/charmbracelet/bubbles/spinner"
|
"github.com/charmbracelet/bubbles/spinner"
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
"github.com/charmbracelet/lipgloss"
|
"github.com/charmbracelet/lipgloss"
|
||||||
zone "github.com/lrstanley/bubblezone"
|
zone "github.com/lrstanley/bubblezone"
|
||||||
@@ -55,6 +57,10 @@ type Model struct {
|
|||||||
detail detail.Model
|
detail detail.Model
|
||||||
footer footer.Model
|
footer footer.Model
|
||||||
spinner spinner.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
|
width, height int
|
||||||
showDetail bool
|
showDetail bool
|
||||||
@@ -64,6 +70,12 @@ func New(ctx context.Context, p submodule.Provider, opts Options) Model {
|
|||||||
t := theme.Default()
|
t := theme.Default()
|
||||||
k := defaultKeys()
|
k := defaultKeys()
|
||||||
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
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{
|
m := Model{
|
||||||
ctx: ctx,
|
ctx: ctx,
|
||||||
provider: p,
|
provider: p,
|
||||||
@@ -73,9 +85,10 @@ 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,
|
||||||
|
filter: fi,
|
||||||
}
|
}
|
||||||
m.fetcher, _ = p.(submodule.Fetcher)
|
m.fetcher, _ = p.(submodule.Fetcher)
|
||||||
m.watcher, _ = p.(submodule.Watcher)
|
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) {
|
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
k := m.keys
|
k := m.keys
|
||||||
|
if m.filter.Focused() {
|
||||||
|
return m.typeFilter(msg)
|
||||||
|
}
|
||||||
switch {
|
switch {
|
||||||
|
case m.filtering && msg.Type == tea.KeyEsc:
|
||||||
|
m.closeFilter()
|
||||||
case key.Matches(msg, k.Quit):
|
case key.Matches(msg, k.Quit):
|
||||||
return m, tea.Quit
|
return m, tea.Quit
|
||||||
case key.Matches(msg, k.Help):
|
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()
|
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.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")
|
cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype")
|
||||||
m.layout()
|
m.layout()
|
||||||
return m, cmd
|
return m, cmd
|
||||||
@@ -361,10 +385,83 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, nil
|
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.
|
// 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)
|
||||||
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
|
m.showDetail = m.width >= detailHideBelow
|
||||||
treeWidth := m.width
|
treeWidth := m.width
|
||||||
@@ -373,6 +470,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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -394,9 +492,9 @@ func (m Model) View() string {
|
|||||||
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
|
body = lipgloss.JoinHorizontal(lipgloss.Top, body, pane)
|
||||||
}
|
}
|
||||||
|
|
||||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left,
|
parts := []string{header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width), body}
|
||||||
header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width),
|
if m.filtering {
|
||||||
body,
|
parts = append(parts, m.filterView())
|
||||||
m.footer.View(),
|
}
|
||||||
))
|
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 {
|
||||||
@@ -73,11 +97,11 @@ func (m Model) lines(s *submodule.Submodule) []string {
|
|||||||
for _, is := range issues {
|
for _, is := range issues {
|
||||||
out = append(out, t.LevelStyle(is.Level, lipgloss.NewStyle()).Width(labelWidth).Render(theme.IssueWord(is.Kind))+text.Render(is.Detail))
|
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"))
|
out = append(out, label(theme.InSync)+muted.Render("nothing to do"))
|
||||||
}
|
}
|
||||||
if st.PinStaged {
|
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 {
|
if st.Checkout == submodule.CheckoutDetached {
|
||||||
out = append(out, label("")+muted.Render("detached HEAD on pin"))
|
out = append(out, label("")+muted.Render("detached HEAD on pin"))
|
||||||
@@ -138,8 +162,19 @@ 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 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")
|
||||||
}
|
}
|
||||||
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
|
||||||
|
}
|
||||||
@@ -12,6 +12,10 @@ import (
|
|||||||
// InSync is the word for a submodule without issues.
|
// InSync is the word for a submodule without issues.
|
||||||
const InSync = "in sync"
|
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.
|
// IssueWord is the short label for an issue kind shown in the tree.
|
||||||
func IssueWord(k submodule.IssueKind) string {
|
func IssueWord(k submodule.IssueKind) string {
|
||||||
switch k {
|
switch k {
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -30,7 +30,7 @@ func (m *Model) rows() []row {
|
|||||||
// appendRows groups mods by the directory of their RelPath. Each group with a
|
// 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.
|
// directory gets a header row; its submodules are indented one level deeper.
|
||||||
func (m *Model) appendRows(out *[]row, mods []*submodule.Submodule, depth int) {
|
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
|
d := depth
|
||||||
if g.dir != "." {
|
if g.dir != "." {
|
||||||
*out = append(*out, row{kind: rowDir, depth: depth, label: 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 {
|
for _, s := range g.mods {
|
||||||
*out = append(*out, row{kind: rowNode, depth: d, node: s})
|
*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)
|
m.appendRows(out, s.Children, d+1)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+42
-13
@@ -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
|
||||||
)
|
)
|
||||||
@@ -25,6 +25,7 @@ type Model struct {
|
|||||||
theme theme.Theme
|
theme theme.Theme
|
||||||
roots []*submodule.Submodule
|
roots []*submodule.Submodule
|
||||||
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
|
expanded map[string]bool // keyed by Submodule.Path; kept when ancestors collapse
|
||||||
|
filter string // lower-case; empty if not filtering
|
||||||
cursor *submodule.Submodule
|
cursor *submodule.Submodule
|
||||||
offset int // index of the first visible row
|
offset int // index of the first visible row
|
||||||
width int
|
width int
|
||||||
@@ -42,9 +43,7 @@ func (m *Model) SetRoots(roots []*submodule.Submodule) {
|
|||||||
m.expanded = map[string]bool{}
|
m.expanded = map[string]bool{}
|
||||||
m.cursor = nil
|
m.cursor = nil
|
||||||
m.offset = 0
|
m.offset = 0
|
||||||
if len(roots) > 0 {
|
m.cursor = m.firstNode()
|
||||||
m.cursor = m.rows()[m.firstNodeRow()].node
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
|
// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the
|
||||||
@@ -63,8 +62,8 @@ func (m *Model) ReplaceRoots(roots []*submodule.Submodule) {
|
|||||||
break
|
break
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if m.cursor == nil && len(roots) > 0 {
|
if m.cursor == nil {
|
||||||
m.cursor = m.rows()[m.firstNodeRow()].node
|
m.reselect(nil)
|
||||||
}
|
}
|
||||||
m.clampOffset()
|
m.clampOffset()
|
||||||
}
|
}
|
||||||
@@ -210,6 +209,15 @@ func (m Model) cursorRow(rows []row) int {
|
|||||||
return -1
|
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 {
|
func (m Model) firstNodeRow() int {
|
||||||
for i, r := range m.rows() {
|
for i, r := range m.rows() {
|
||||||
if r.kind == rowNode {
|
if r.kind == rowNode {
|
||||||
@@ -304,6 +312,9 @@ func (m Model) View() string {
|
|||||||
lines = append(lines, m.nodeLine(r, nameColW, refW))
|
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 {
|
for len(lines) < m.height {
|
||||||
lines = append(lines, strings.Repeat(" ", m.width))
|
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 {
|
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 ""
|
||||||
}
|
}
|
||||||
return fmt.Sprintf(" [%d]", len(s.Children))
|
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))) }
|
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
|
// toggle marker
|
||||||
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))
|
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;
|
// 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
|
var status string
|
||||||
switch {
|
switch {
|
||||||
case s.Err != nil:
|
case s.Err != nil:
|
||||||
@@ -411,14 +427,27 @@ func (m Model) nodeLine(r row, nameColW, refW int) string {
|
|||||||
if len(issues) > 1 {
|
if len(issues) > 1 {
|
||||||
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
|
status += seg(t.Muted, fmt.Sprintf(" +%d", len(issues)-1))
|
||||||
}
|
}
|
||||||
|
case staged:
|
||||||
|
status = seg(t.Info, theme.Staged)
|
||||||
default:
|
default:
|
||||||
status = seg(t.Muted, theme.InSync)
|
status = seg(t.Muted, theme.InSync)
|
||||||
}
|
}
|
||||||
if k, worst := s.HiddenIssues(); collapsed && k > 0 {
|
if stagedMark || collapsed && h.Staged > 0 {
|
||||||
if status != "" {
|
status += pad(1) + seg(t.Info, t.Glyphs.Staged)
|
||||||
status += pad(1)
|
}
|
||||||
|
if collapsed && h.Total() > 0 {
|
||||||
|
for _, c := range []struct {
|
||||||
|
level submodule.Level
|
||||||
|
n int
|
||||||
|
}{{submodule.LevelDanger, h.Danger}, {submodule.LevelWarn, h.Warn}} {
|
||||||
|
if c.n == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if status != "" {
|
||||||
|
status += pad(1)
|
||||||
|
}
|
||||||
|
status += t.LevelStyle(c.level, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, c.n))
|
||||||
}
|
}
|
||||||
status += t.LevelStyle(worst, base).Render(fmt.Sprintf("%s%d", t.Glyphs.Problems, k))
|
|
||||||
}
|
}
|
||||||
status = ansi.Truncate(status, statusWidth, "")
|
status = ansi.Truncate(status, statusWidth, "")
|
||||||
status += pad(statusWidth - lipgloss.Width(status))
|
status += pad(statusWidth - lipgloss.Width(status))
|
||||||
|
|||||||
@@ -249,3 +249,59 @@ func TestExpandAllOnLeafKeepsSelection(t *testing.T) {
|
|||||||
t.Fatal("all nodes with children should be expanded")
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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