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