Add problems-only view (!) and draft the write actions

`!` reduces the tree to submodules with issues (or unknown state) plus
their ancestors, reusing the filter's reduction. Adds docs/actions.md as
a draft spec for restore and stage pin.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-22 12:12:11 +02:00
co-authored by Claude Sonnet 5.5
parent 72c060882b
commit 928e76154f
10 changed files with 484 additions and 25 deletions
+3 -2
View File
@@ -109,6 +109,7 @@ files.
| `space` | Toggle the selected submodule | | `space` | Toggle the selected submodule |
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page | | `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
| `/` | Filter | | `/` | Filter |
| `!` | Problems only |
| `c` | Copy path | | `c` | Copy path |
| `f` / `r` | Fetch / reload | | `f` / `r` | Fetch / reload |
| `?` | Full help | | `?` | Full help |
@@ -118,8 +119,8 @@ The mouse works too: click to select, click `▸` to toggle, wheel to move.
## Status ## Status
A prototype. Browsing is complete; the actions `u` (check out the pin), A prototype. Browsing is complete; the actions `u` (check out the pin) and `b` (bump the pin to the tip) are
`b` (bump the pin to the tip) and `!` (problems only) are planned. planned.
## Documentation ## Documentation
+5 -3
View File
@@ -14,6 +14,7 @@ bare commit hash.
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs | | [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs |
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings | | [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing | | [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
| [actions.md](actions.md) | **Draft:** the write actions restore and stage pin |
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) | | [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
## Install ## Install
@@ -32,10 +33,11 @@ Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
through a git provider (`internal/submodule/git`): `submarine [dir]`. It 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`). `!` shows only
`!` only show a "not implemented" message. submodules with problems. The actions `u` and `b` only show a "not
implemented" message ([drafts of the write actions](actions.md)).
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. "Problems only" (`!`) view; it can reuse the tree's filter. 2. (done) "Problems only" (`!`) view, reusing the tree's filter.
+262
View File
@@ -0,0 +1,262 @@
# Actions (draft)
**Status: draft, not implemented.** The spec for the first two write actions.
Open questions are listed [at the end](#open-questions). Once they are
answered, the decisions go into [decisions.md](decisions.md), and
[requirements.md](requirements.md) and [ux-guide.md](ux-guide.md) are
updated.
## Principle
Offer as few actions as possible, but enough to work with submodules.
submarine shows what differs from what git records. The actions resolve such
a difference in one of two directions:
| Action | Direction | Key (proposed) |
|---|---|---|
| [Restore](#restore) | The worktree follows the recorded state | `u` |
| [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` |
There is nothing else for now. Committing, pushing, switching branches and
bumping a pin to the tip are done outside submarine. A bump is "check out
the tip in the submodule, then `s`".
## Restore
Makes the selected repository, and everything below it, exactly what git
records. It works on any submodule and on the superproject (header
selected). Restoring the superproject gives the exact project that the
current root commit specifies.
### Target
| Selected | Target commit | Branch |
|---|---|---|
| Superproject | `HEAD` | stays on the current branch |
| Submodule | The pin in the parent's **index** (what the UI shows as *Pinned*) | see [Branches](#branches) |
| Nested submodule, during the recursion | The gitlink in the restored parent's commit | as for a submodule |
Restoring the superproject resets its index too, so **staged pins are
discarded**: afterwards every pin is the one in `HEAD`. Restoring a submodule
does not touch the parent's index, so a staged pin of that submodule stays
and is the target. (See open question 2.)
### Postconditions
After a successful restore of repository R with target T:
1. **R's HEAD is T.** The index and the worktree equal T's tree. No
modified, added, removed or conflicted files remain.
2. **No in-progress operation** remains in R: merge, rebase, cherry-pick,
revert.
3. **No untracked files or directories** remain in R. This includes leftover
directories of submodules that T no longer has (*untracked repository*).
Ignored files are kept (see open question 1).
4. **Every submodule that T records is present and initialized**, even if
it was never initialized, is inactive (`submodule.<name>.active`) or has
`update = none / merge / rebase` configured. Its URL comes from T's
`.gitmodules` (synced into the config).
5. **Each of those submodules is restored the same way**, with its gitlink
in T as its target, recursively at any depth.
So the subtree shows `in sync` everywhere, except for states restore does
not change: `unpushed`, behind/ahead of the ref, and `detached HEAD on pin`.
The recursion follows **T**, not the tree loaded in the UI. A submodule that
is only in T (for example, after switching branches) is initialized. A
submodule that is only on disk is deleted as an untracked directory.
### Branches
- **Superproject:** stays on its branch. The branch is not moved, since the
target is its own `HEAD`.
- **Submodule on the pin:** if HEAD is a branch that points at the target,
the branch stays checked out.
- **Submodule off the pin:** HEAD is detached at the target. The branch that
was checked out is **not moved**, so its commits stay on it.
Restore never moves, creates or deletes a branch.
### What can be lost
Restore discards work. The things it can lose:
| Lost | Where it comes from |
|---|---|
| Changed files | modified, added, removed, renamed files (`changed`) |
| Untracked files | `changed` (untracked) |
| Conflicts and in-progress operations | `conflict`; merge, rebase, cherry-pick, revert state |
| Commits | A detached HEAD off the pin whose commits are on no branch, tag or remote ref (only the reflog still has them) |
| Repositories | An untracked directory that is a repository with its **own** `.git` directory (a clone that was never added as a submodule). Its commits may exist nowhere else. A removed submodule whose git directory is absorbed into `.git/modules` loses only its worktree. Its objects stay |
Nothing else is lost. Branches, stashes, remote refs, ignored files and
absorbed submodule git directories stay.
### Confirmation
- **Nothing would be lost** (only `off pin`, `missing`, a staged pin of the
root): restore runs at once, without a question.
- **Something would be lost:** a confirmation line opens above the footer,
like the filter line. It says what is discarded, summed over the subtree:
```
Restore src/app? Discards 7 changed files in 3 submodules, 2 detached commits, 1 repository. y restore · n cancel
```
`y` runs it. `n`, `esc` or any other key cancels.
- **The state is not fully known** (a status in the subtree is still
loading or failed to load): the line says so (`… · 2 submodules not
loaded`) and needs the same `y`. Restore does not wait for the loading.
- Lost repositories and commits are shown in the Danger color, files in
Warn.
The summary only covers the submodules that are loaded. Children of a
submodule that is not initialized are unknown. They are created fresh, so
nothing of them can be lost.
### Network
A restore may need objects that are not on disk:
- a submodule that was never cloned (`not initialized`, no git directory in
`.git/modules`);
- a target commit that was never fetched.
Proposal: **restore may clone and fetch, but only for objects that are
missing**, and never prompts (`GIT_TERMINAL_PROMPT=0`, as for `Fetch`).
This changes the rule "the provider uses the network only in `Fetch`"
(AGENTS.md, N8). The alternative is to fail with "pinned commit … not
fetched" and leave cloning to the user (open question 3).
### Running, progress and errors
- The selected row (or the header) shows a spinner and `restoring` in the
status column until the restore ends. The header shows `restoring src/app`
where the fetch progress usually is.
- Only one write action runs at a time. Pressing `u` or `s` meanwhile
flashes `wait for the restore of src/app to finish`. Navigation and
everything else keep working.
- A running background fetch does not block a restore.
- Done: flash `restored src/app`. The watcher refreshes the affected rows.
The app also reloads the subtree, because the structure can have changed.
- Failure: the restore stops at the first repository that fails. The footer
flashes `restore failed in src/app/lib: <git's message>`, and the detail
pane of the selected node shows the same message until the next restore.
Restore is idempotent, so it can be run again after fixing the cause.
### Implementation note
For each repository, top-down (`T` = target):
```sh
git merge --abort / rebase --abort / cherry-pick --abort / revert --abort # whichever is in progress
git checkout --detach --force T # submodule off the pin; superproject: git reset --hard HEAD
git reset --hard T
git clean -ffd # -ff: also untracked nested repositories
git submodule sync
git submodule init # all submodules of T, including inactive ones
# for each gitlink in T: make sure the repo exists (clone/absorb), then recurse
```
The repository can be walked by submarine itself, not by `git submodule
update --recursive`, so progress, the loss check and errors are per
repository. `git submodule update --init --checkout --force` is an option for
the clone step. The loss check runs right before the restore, against the
current state, not the displayed one.
## Stage pin
Records the commit checked out in a submodule as its new pin in the
parent's index. It is the same as `git add <path>` in the parent, but only
when it is safe.
### Available when
All of:
- the selected node is a submodule, not the superproject;
- it is `off pin` (`Checkout == CheckoutDiffers`): HEAD differs from the
pin in the parent's index;
- its worktree is **clean** (`Worktree == WorktreeClean`). Uncommitted
changes, untracked files and conflicts count as dirty, the same rule as
the `changed` issue. A moved nested pin counts as a change too, so nested
submodules are staged bottom-up: first inside the submodule, then the
submodule itself (after a commit in it, outside submarine).
Changes *inside* nested submodules don't count. Like the `changed` issue,
staging only looks at the submodule's own worktree.
### Effect
- The parent's index records the submodule's HEAD as the gitlink of its
path. Nothing else changes: no worktree, no other index entry, no commit.
- The submodule then shows `staged` (or `●` next to an issue), and the
`off pin` issue is gone.
- If the checkout equals the pin in the parent's `HEAD`, staging removes
the staged pin instead (the index equals `HEAD` again).
- If the checked-out commit is not pushed, it is staged anyway. The row
then shows `unpushed` (Danger), which is the warning.
- There is no confirmation: nothing is lost.
- Undo: there is no unstage action. `git restore --staged <path>` in the
parent undoes it. Restoring the superproject also discards every staged
pin, together with everything else.
### Implementation note
```sh
# in the submodule
git rev-parse HEAD # must still be the SHA shown in the UI, else abort
# in the parent
git update-index --cacheinfo 160000,<sha>,<path>
```
`update-index` stages exactly the SHA that was checked, independent of the
worktree. Flash: `staged pin of src/app` on success; git's message on
failure.
## Keys and palette
Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's
palette, or in the footer, **only while they apply** to the selection:
| Key | Shown when | Pressed when not applicable (flash) |
|---|---|---|
| `u` restore | The node or a loaded descendant has an issue restore fixes (`changed`, `conflict`, `missing`, `off pin`), or the root has a staged pin | `nothing to restore in src/app` |
| `s` stage pin | [Available](#available-when) | `src/app is on its pin` / `src/app has uncommitted changes; commit or restore them first` / `the superproject has no pin` |
Full help (`?`) always lists both.
## Testing
- Provider: tests on fixtures from `scripts/make-fixture.sh`, which builds
every state. For restore: changed, untracked, untracked repository
(absorbed and standalone), conflict, rebase in progress, off pin on a
branch, off pin detached with unreachable commits, not initialized,
inactive, `update = none`, a submodule added and one removed between two
root commits, nested at depth ≥ 2. Assert the postconditions with
`git status --porcelain --ignored=no` and `git submodule status
--recursive`, and that branches are unchanged. For stage pin: index entry,
refusal on a dirty worktree, refusal when HEAD moved.
- Loss summary: unit tests on `submodule` (no UI), like `Status.Issues()`.
- UI: run it on a fixture in tmux (see
[architecture.md](architecture.md#testing)). Check the confirmation line,
the spinner, the flash messages and the palette.
## Open questions
1. **Ignored files.** Should restore also delete ignored files (`git clean
-x`)? That is the literal "no additional files", but it deletes `.env`,
`node_modules`, build output and IDE settings in every submodule.
*Proposal: keep ignored files.*
2. **Target of a submodule restore with a staged pin.** The staged pin
(index, what the UI shows as *Pinned*, what `git submodule update` does)
or the committed one (parent's `HEAD`, which also unstages)?
*Proposal: the index. Unstaging happens only through a superproject
restore.*
3. **Network.** May restore clone uninitialized submodules and fetch missing
pins (non-interactive), or should it fail and ask for `f` / a manual
clone? *Proposal: allow it, only for missing objects.*
4. **Keys.** `u` restore, `s` stage pin? `R` would sit next to `r` reload,
which is a dangerous mix-up for a destructive action.
5. **Confirmation without loss.** Is "no question when nothing is lost" OK,
or should restore always ask?
+14
View File
@@ -282,3 +282,17 @@ problem is visible while a submodule is selected.
Without an upstream the usual resolution applies. Without an upstream the usual resolution applies.
- The root is not in `Walk`, so counts, fetch and loading logic stay - The root is not in `Walk`, so counts, fetch and loading logic stay
submodule-only; the app tracks it by the path `""`. submodule-only; the app tracks it by the path `""`.
## Problems only reuses the filter's tree reduction
`!` shows only submodules with issues. Instead of a second view, the tree's
filter got a second criterion: a submodule matches if its path contains the
term *and*, with problems only, it has an issue. Ancestors of matches stay as
context, expanded, so a danger deep in the tree is reachable without
unfolding anything. Unlike the filter text it is a mode that outlives the
filter line, so it has its own notice line above the footer: a short list
must not look like a healthy repository (D4). Loading and failed submodules
count as problems because their state is unknown (D7 counts `error` as danger
too); the cost is that rows vanish as statuses arrive, so the selection is
refitted after each status. Staged pins are not shown by it, since they are
not issues.
+9 -1
View File
@@ -120,13 +120,21 @@ commit hashes. It doesn't answer the questions users actually have:
submodule (none if nothing matches); closing the filter keeps the submodule (none if nothing matches); closing the filter keeps the
selection (the superproject if none is left). See [Filter](ux-guide.md#filter). selection (the superproject if none is left). See [Filter](ux-guide.md#filter).
- **F55** `!` toggles "problems only": the tree shows only submodules with
an issue (**F10**) or an unknown state (loading, `error`), plus their
ancestors, expanded. It combines with the filter (**F54**) and stays when
the filter closes. A line above the footer says `problems only`, so a short
list is not mistaken for a healthy repository. See
[Problems only](ux-guide.md#problems-only).
### 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 |
| `!` | Show only submodules with issues |
See [actions.md](actions.md) for the draft of the write actions.
## Usage ## Usage
+26 -2
View File
@@ -220,7 +220,8 @@ colors in components.
| `r` | Reload everything (e.g. after editing files) | | `r` | Reload everything (e.g. after editing files) |
| `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied <path>`. Without a local provider (mock) the path relative to the superproject is copied | | `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) | | `/` | Open the filter, see [Filter](#filter) |
| `u`, `b`, `!` | Planned actions (see requirements) | | `!` | Toggle problems only, see [Problems only](#problems-only) |
| `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).
@@ -250,6 +251,29 @@ match vanish. If nothing matches the tree says `no submodule matches`.
- The filter line shows the keys that apply on its right. - The filter line shows the keys that apply on its right.
- The header counts are not filtered. - The header counts are not filtered.
### Problems only
`!` reduces the tree to what needs attention. It uses the filter's rules
(matches with their ancestors, shown expanded, see [Filter](#filter)), with
"match" meaning *has an issue* (`Status.Issues()`) instead of *path contains
the term*.
- A submodule that is still loading or failed to load (`error`) counts as a
problem, since its state is unknown (as in the hidden counts, D7). Healthy
submodules vanish when their status arrives.
- A staged pin is not an issue and does not show the row.
- It combines with the filter (both must match). Unlike the filter it is a
mode, not an input: it stays until `!` is pressed again, also after `esc`
closed the filter.
- A line above the footer shows `problems only` (yellow) and `! show all`.
While the filter line is open, its hint starts with `problems only (!)`
instead.
- With no problem the tree says `no problems`.
- The selection follows the filter's rule: if it stops being visible (also
when a status changes), the next visible submodule is selected, else the
previous one. The superproject stays selectable.
- The header counts are not filtered.
### Key reference ### Key reference
Every key in the UI, where it is shown, and whether it works. Update this Every key in the UI, where it is shown, and whether it works. Update this
@@ -272,7 +296,7 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
| `f` | Fetch all repositories | Global | Help | yes | | `f` | Fetch all repositories | Global | Help | yes |
| `r` | Reload everything | Global | Help | yes | | `r` | Reload everything | Global | Help | yes |
| `/` | Filter | Global | Help | yes | | `/` | Filter | Global | Help | yes |
| `!` | Problems only | Global | Help | no (flashes "not implemented") | | `!` | Problems only (toggle) | Global | Help | yes |
| `?` | Toggle full help | Global | Bar, Help | yes | | `?` | Toggle full help | Global | Bar, Help | yes |
| `q`, `ctrl+c` | Quit | Global | Bar, Help | yes | | `q`, `ctrl+c` | Quit | Global | Bar, Help | yes |
+24 -2
View File
@@ -199,6 +199,7 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} else { } else {
s.Status, s.Err = msg.status, nil s.Status, s.Err = msg.status, nil
} }
m.tree.Refit()
} }
if m.loads.done(msg.path) { if m.loads.done(msg.path) {
return m, m.loadStatus(msg.path) return m, m.loadStatus(msg.path)
@@ -386,7 +387,10 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.filtering = true m.filtering = true
m.layout() m.layout()
return m, m.filter.Focus() return m, m.filter.Focus()
case key.Matches(msg, k.Update, k.Bump, k.Issues): case key.Matches(msg, k.Issues):
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
m.layout()
case key.Matches(msg, k.Update, k.Bump):
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
@@ -438,6 +442,9 @@ func (m Model) filterView() string {
if !m.filter.Focused() { if !m.filter.Focused() {
hint = "/ edit • esc close" hint = "/ edit • esc close"
} }
if m.tree.ProblemsOnly() {
hint = "problems only (!) • " + hint
}
hint = theme.Fg(m.theme.Muted).Render(hint) hint = theme.Fg(m.theme.Muted).Render(hint)
m.filter.Width = max(m.width-lipgloss.Width(hint)-6, 1) m.filter.Width = max(m.width-lipgloss.Width(hint)-6, 1)
in := m.filter.View() in := m.filter.View()
@@ -445,6 +452,19 @@ func (m Model) filterView() string {
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint) return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(in + strings.Repeat(" ", gap) + hint)
} }
// extraLine reports whether a line above the footer is shown: the filter, or
// the notice that only problems are shown.
func (m Model) extraLine() bool { return m.filtering || m.tree.ProblemsOnly() }
// problemsView is the line above the footer while only problems are shown, so
// a short list is not mistaken for a healthy repository.
func (m Model) problemsView() string {
left := theme.Fg(m.theme.Warn).Render("problems only")
right := theme.Fg(m.theme.Muted).Render("! show all")
gap := max(m.width-2-lipgloss.Width(left)-lipgloss.Width(right), 1)
return lipgloss.NewStyle().Width(m.width).Padding(0, 1).Render(left + strings.Repeat(" ", gap) + right)
}
// copyPath copies the absolute directory of the selected submodule to the // copyPath copies the absolute directory of the selected submodule to the
// clipboard, or its path in the superproject if the provider can't tell. // clipboard, or its path in the superproject if the provider can't tell.
func (m Model) copyPath() (tea.Model, tea.Cmd) { func (m Model) copyPath() (tea.Model, tea.Cmd) {
@@ -470,7 +490,7 @@ func (m Model) copyPath() (tea.Model, tea.Cmd) {
func (m *Model) layout() { func (m *Model) layout() {
m.footer.SetWidth(m.width) m.footer.SetWidth(m.width)
bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View()) bodyHeight := m.height - headerHeight - lipgloss.Height(m.footer.View())
if m.filtering { if m.extraLine() {
bodyHeight-- bodyHeight--
} }
bodyHeight = max(bodyHeight, 0) bodyHeight = max(bodyHeight, 0)
@@ -508,6 +528,8 @@ func (m Model) View() string {
parts := []string{zone.Mark(m.tree.RootZone(), head), body} parts := []string{zone.Mark(m.tree.RootZone(), head), body}
if m.filtering { if m.filtering {
parts = append(parts, m.filterView()) parts = append(parts, m.filterView())
} else if m.tree.ProblemsOnly() {
parts = append(parts, m.problemsView())
} }
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...)) return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
} }
+48 -8
View File
@@ -8,7 +8,7 @@ import (
// SetFilter shows only the submodules whose path contains term (ignoring // SetFilter shows only the submodules whose path contains term (ignoring
// case), plus their ancestors, which are shown expanded. An empty term // case), plus their ancestors, which are shown expanded. An empty term
// removes the filter. // removes the filter. It combines with SetProblemsOnly.
// //
// If the selected submodule is filtered out, the next visible one is // 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 // selected (the previous one if there is none after it, none if nothing
@@ -21,15 +21,46 @@ func (m *Model) SetFilter(term string) {
} }
old := m.cursor old := m.cursor
m.filter = term m.filter = term
if term == "" { m.narrow(old)
}
// SetProblemsOnly shows only the submodules with issues, plus their
// ancestors, which are shown expanded. A submodule whose status is loading or
// failed to load counts as a problem, since its state is unknown. The
// selection behaves as with SetFilter.
func (m *Model) SetProblemsOnly(on bool) {
if on == m.problemsOnly {
return
}
old := m.cursor
m.problemsOnly = on
m.narrow(old)
}
// ProblemsOnly reports whether only submodules with issues are shown.
func (m Model) ProblemsOnly() bool { return m.problemsOnly }
// Refit keeps the selection visible after statuses changed, which can make a
// submodule enter or leave the problems-only view.
func (m *Model) Refit() {
if m.problemsOnly {
m.reselect(m.cursor)
m.clampOffset()
}
}
// narrow fixes the selection after the filter or the problems-only view
// changed.
func (m *Model) narrow(old *submodule.Submodule) {
if m.narrowed() {
m.reselect(old)
} else {
for p := parentOf(old); p != nil; p = p.Parent { for p := parentOf(old); p != nil; p = p.Parent {
m.expanded[p.Path] = true m.expanded[p.Path] = true
} }
if m.cursor == nil { if m.cursor == nil {
m.cursor = m.firstNode() m.cursor = m.firstNode()
} }
} else {
m.reselect(old)
} }
m.clampOffset() m.clampOffset()
} }
@@ -37,6 +68,9 @@ func (m *Model) SetFilter(term string) {
// Filter returns the active filter term in lower case. // Filter returns the active filter term in lower case.
func (m Model) Filter() string { return m.filter } func (m Model) Filter() string { return m.filter }
// narrowed reports whether the tree is reduced to matches.
func (m Model) narrowed() bool { return m.filter != "" || m.problemsOnly }
func parentOf(s *submodule.Submodule) *submodule.Submodule { func parentOf(s *submodule.Submodule) *submodule.Submodule {
if s == nil { if s == nil {
return nil return nil
@@ -106,10 +140,10 @@ func (m *Model) appendAll(out *[]row, mods []*submodule.Submodule, depth int) {
} }
} }
// isExpanded reports whether the children of s are shown. While filtering, // isExpanded reports whether the children of s are shown. While narrowed,
// that is the case if a match is below s, whatever the user expanded. // that is the case if a match is below s, whatever the user expanded.
func (m *Model) isExpanded(s *submodule.Submodule) bool { func (m *Model) isExpanded(s *submodule.Submodule) bool {
if m.filter == "" { if !m.narrowed() {
return m.expanded[s.Path] return m.expanded[s.Path]
} }
for _, c := range s.Children { for _, c := range s.Children {
@@ -122,7 +156,7 @@ func (m *Model) isExpanded(s *submodule.Submodule) bool {
// filtered returns the submodules of mods that pass the filter. // filtered returns the submodules of mods that pass the filter.
func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule { func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
if m.filter == "" { if !m.narrowed() {
return mods return mods
} }
var out []*submodule.Submodule var out []*submodule.Submodule
@@ -136,7 +170,7 @@ func (m *Model) filtered(mods []*submodule.Submodule) []*submodule.Submodule {
// shown reports whether s matches the filter or has a descendant that does. // shown reports whether s matches the filter or has a descendant that does.
func (m *Model) shown(s *submodule.Submodule) bool { func (m *Model) shown(s *submodule.Submodule) bool {
if strings.Contains(strings.ToLower(s.Path), m.filter) { if strings.Contains(strings.ToLower(s.Path), m.filter) && (!m.problemsOnly || hasProblem(s)) {
return true return true
} }
for _, c := range s.Children { for _, c := range s.Children {
@@ -146,3 +180,9 @@ func (m *Model) shown(s *submodule.Submodule) bool {
} }
return false return false
} }
// hasProblem reports whether s has an issue or an unknown state (loading, or
// failed to load, like HiddenIn counts it).
func hasProblem(s *submodule.Submodule) bool {
return s.Status == nil || len(s.Status.Issues()) > 0
}
+8 -2
View File
@@ -27,6 +27,8 @@ type Model struct {
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 filter string // lower-case; empty if not filtering
// problemsOnly hides submodules without issues; it combines with filter.
problemsOnly bool
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
@@ -365,8 +367,12 @@ 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 != "" { if len(rows) == 0 && m.narrowed() {
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(" no submodule matches")) none := " no submodule matches"
if m.filter == "" {
none = " no problems"
}
lines = append(lines, theme.Fg(m.theme.Muted).Width(m.width).Render(none))
} }
for len(lines) < m.height { for len(lines) < m.height {
lines = append(lines, strings.Repeat(" ", m.width)) lines = append(lines, strings.Repeat(" ", m.width))
+80
View File
@@ -411,3 +411,83 @@ func TestFilterKeepsRoot(t *testing.T) {
t.Error("closing the filter should keep the root") t.Error("closing the filter should keep the root")
} }
} }
// healthy gives every submodule of the fixture an issue-free status, then
// marks the given paths as changed.
func healthy(t *testing.T, m *Model, changed ...string) {
t.Helper()
submodule.Walk(m.roots, func(s *submodule.Submodule) {
s.Status = &submodule.Status{Pin: submodule.PinInfo{Pushed: true}}
for _, p := range changed {
if s.Path == p {
s.Status.Worktree = submodule.WorktreeModified
}
}
})
}
func TestProblemsOnlyShowsIssuesWithAncestors(t *testing.T) {
m := newTree(t)
healthy(t, m, "d")
m.SetProblemsOnly(true)
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 healthy: next available
m.Select("d")
m.SetProblemsOnly(false)
assertCursor(t, m, "d")
if got := visible(m); len(got) != 8 {
t.Fatalf("rows = %v, want all expanded to d", got)
}
}
func TestProblemsOnlyKeepsUnknownStates(t *testing.T) {
m := newTree(t)
healthy(t, m)
f := m.roots[2]
f.Status, f.Err = nil, os.ErrNotExist // failed to load
m.roots[0].Status = nil // still loading
m.SetProblemsOnly(true)
if got, want := visible(m), []string{"a", "src/", "f"}; !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
}
func TestProblemsOnlyWithoutProblemsKeepsRoot(t *testing.T) {
m := New(theme.Default())
m.SetRoot(&submodule.Submodule{Status: &submodule.Status{Root: true}})
m.SetRoots(fixture())
m.SetSize(80, 20)
healthy(t, &m)
m.SetProblemsOnly(true)
if len(m.rows()) != 0 || !m.RootSelected() {
t.Fatalf("rows %v, root selected %v", visible(&m), m.RootSelected())
}
m.Down() // must not panic
}
func TestProblemsOnlyCombinesWithFilter(t *testing.T) {
m := newTree(t)
healthy(t, m, "d", "f")
m.SetProblemsOnly(true)
m.SetFilter("f")
if got, want := visible(m), []string{"src/", "f"}; !equal(got, want) {
t.Fatalf("rows = %v, want %v", got, want)
}
m.SetFilter("")
if !m.ProblemsOnly() || len(visible(m)) != 6 {
t.Fatalf("closing the filter keeps problems only: %v", visible(m))
}
}
func TestRefitMovesSelectionWhenProblemDisappears(t *testing.T) {
m := newTree(t)
healthy(t, m, "a", "f")
m.SetProblemsOnly(true)
assertCursor(t, m, "a")
m.roots[0].Status.Worktree = submodule.WorktreeClean
m.Refit()
assertCursor(t, m, "f")
}