4 Commits
Author SHA1 Message Date
simonfrankenandClaude Opus 5.5 c9932932bb Make the screen explain itself: labels, tools, help pages, hints
For developers new to submodules (notes/usability-plan.md, D32):
- x and X read "restore to pin" and "restore committed pins"
- problems only, filter and fetch are shown in the footer
- ? shows every key in titled columns, then a legend of the status
  words, symbols and spine roles; esc closes it
- u reads "pull (fast-forward)" on the superproject
- the detail pane suggests f when the last fetch is old
- a hint above the footer until the first key
- the header says "staged", like the tree

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:26:32 +02:00
simonfrankenandClaude Opus 5.5 b205576759 Hard restore with X asks before dropping staged pins
X dropped staged pins without a question, including the selected
submodule's own pin, which the loss check didn't count. A staged pin is
a decision, and X sits one Shift away from x, so dropped staged pins now
count as a loss and make X ask (D31, amends D30).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 23:26:32 +02:00
simonfrankenandClaude Opus 5.5 72500353cd Hard restore with X drops staged pins at every depth
X restores the selection to the committed pins without the staged or
committed question, and resets every restored repository's index to
HEAD, so staged pins below are dropped too. Dropped staged pins alone
don't make it ask; they are listed if something else is lost.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:58:08 +02:00
simonfrankenandClaude Opus 5.5 fbd233f58d Update a pin that is behind with u; switch fast-forwards
`u` moves the pin of the selection to the tip of its ref when the pin is
only behind it: a switch to that branch with exactly the tip the row
shows, nothing fetched; if the ref moved meanwhile, nothing happens. On
the superproject it fast-forwards the branch to its upstream and restores
the submodules (git pull --ff-only from the last fetch).

Switch now fast-forwards a local branch that is only behind the chosen
remote branch, unless another worktree has it checked out, and keeps a
checked-out branch that tracks it under another name. A branch with
commits of its own is still never moved.

Stage pin is dropped: switching to the branch already stages it. The
detail pane's palette wraps instead of cutting off keys. Add D29 and F59.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-24 22:39:18 +02:00
29 changed files with 1799 additions and 214 deletions
+27 -9
View File
@@ -29,8 +29,9 @@ the questions you actually have:
seconds. It never fetches on its own: the detail pane shows when a
repository was last fetched, `f` fetches everything.
- **Fix it from the same screen.** Restore a repository to exactly what git
records (`x`), or switch it to another branch (`b`). Both ask first if work
would be lost.
records (`x`), switch it to another branch (`b`), or move a pin that is
behind to the tip of its branch (`u`). They ask first if work would be
lost.
- **Built for dozens to hundreds of submodules.** Each status loads
asynchronously, so the UI never freezes.
@@ -116,6 +117,10 @@ something would be lost, you are asked first:
submodule with a staged pin, `x` first asks whether to restore to the staged
pin (`s`) or the committed one (`c`, which unstages it).
`X` restores to the committed pins without asking which one, and also
drops the staged pins of submodules below the selection, which `x` keeps
where it can. It asks if work or a staged pin would be lost.
### Switch a branch with `b`
Press `b` on a submodule (or the header for the superproject). A popup lists
@@ -133,11 +138,22 @@ the local and remote branches, newest first; type to filter.
`↑` `↓` select, `enter` switches, `esc` cancels. The tip of the branch
becomes the submodule's staged pin and it is restored to it, with HEAD on the
branch. Picking `origin/feat` creates a local `feat` that tracks it; an
existing local branch of that name is never moved. Like restore, it asks
branch. Picking `origin/feat` creates a local `feat` that tracks it. Like restore, it asks
first if work would be lost. The list is read from disk, so press `f` first
to see new remote branches.
An existing local branch that is only behind the remote branch is
fast-forwarded; one with commits of its own is never moved.
### Update a pin with `u`
On a submodule whose pin is behind its branch (`origin/main ↓2`), `u` moves
the pin to that branch's tip: the same as picking `origin/main` with `b`,
without the popup. On the header it fast-forwards the superproject's branch
to its upstream (`main ↓1`), like `git pull --ff-only`, and restores the
submodules. It takes exactly the commits shown and fetches nothing; press
`f` first to see newer ones.
### Fetch and reload
`f` fetches all repositories again, `r` reloads everything after you edited
@@ -152,13 +168,15 @@ files.
| `H` `L` / `⇧←` `⇧→` | Collapse / expand all |
| `space` | Toggle the selected submodule |
| `home` `end` / `g` `G`, `pgup` `pgdn` | Jump, page |
| `/` | Filter |
| `!` | Problems only |
| `/` | Filter |
| `c` | Copy path |
| `u` | Update: move a pin that is behind to the tip of its branch (staged, like `b`); on the header, fast-forward the superproject |
| `b` | Switch branch: pick a local or remote branch; its tip is staged and the repository restored to it |
| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) |
| `x` | Restore to pin: make the worktree exactly what git records (asks if work would be lost) |
| `X` | Restore committed pins: like `x`, but dropping all staged pins below (asks first) |
| `f` / `r` | Fetch / reload |
| `?` | Full help |
| `?` | Full help: every key, then what the words and symbols mean (`esc` closes it) |
| `q` | Quit. If a newer release exists, the footer offers `q quit and update to vX` (highlighted) and plain quit moves to `Q` |
The mouse works too: click to select, click `▸` to toggle, wheel to move. In
@@ -167,8 +185,8 @@ click outside to close it.
## Status
A prototype. Browsing is complete. Two write actions exist: restore (`x`)
and switch branch (`b`). More (stage pin) are being specified in
A prototype. Browsing is complete. Three write actions exist: restore (`x`),
switch branch (`b`) and update pin (`u`), see
[docs/actions.md](docs/actions.md).
## Documentation
+10 -4
View File
@@ -14,7 +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 |
| [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 |
| [actions.md](actions.md) | the write actions restore and switch branch (done) and stage pin (draft) |
| [actions.md](actions.md) | the write actions restore, switch branch and update pin |
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
## Install
@@ -33,10 +33,16 @@ Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows
the built-in mock data (`internal/submodule/mock`). `!` shows only
submodules with problems. `x` restores the selection and `b` switches its
branch ([actions.md](actions.md)); stage pin is drafted there.
submodules with problems. `x` restores the selection (`X` also drops
staged pins), `b` switches its
branch and `u` updates a pin that is behind ([actions.md](actions.md)).
Next steps, roughly in order:
1. Stage pin, see [actions.md](actions.md). (Restore and switch branch are done.)
1. (done) Write actions: restore, switch branch, update pin, see
[actions.md](actions.md). Stage pin was dropped (D29).
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
3. Usability pass for users new to submodules, see
[notes/usability-plan.md](../notes/usability-plan.md). The low-effort
items are done (D31, D32); next are the issue hints and the palette
that fits the selection.
+113 -63
View File
@@ -1,8 +1,10 @@
# Actions (draft)
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) are implemented. [Stage pin](#stage-pin) is still a draft; its open
questions are [at the end](#open-questions).
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md); hard restore
`X`, [D30](decisions.md)),
[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) and
[Update pin](#update-pin) (key `u`, [D29](decisions.md)) are implemented.
Stage pin was dropped ([D29](decisions.md)).
## Principle
@@ -12,13 +14,13 @@ a difference in one of two directions:
| Action | Direction | Key |
|---|---|---|
| [Restore](#restore) | The worktree follows the recorded state | `x` |
| [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` |
| [Restore](#restore) | The worktree follows the recorded state | `x`, `X` (hard) |
| [Switch branch](#switch-branch) | Both: the branch tip is recorded, then restored to | `b` |
| [Update pin](#update-pin) | Both: a switch to the tip of the pin's ref | `u` |
There is nothing else for now. Committing, pushing and bumping a pin to the
tip are done outside submarine. A bump is "check out the tip in the
submodule, then `s`" (or switch to the branch with `b`).
There is nothing else for now. Committing and pushing are done outside
submarine. Recording a commit made in a submodule is a switch to its branch
(`b`); a commit on no branch must get one first.
## Restore
@@ -65,6 +67,33 @@ Two exceptions, where a staged pin is not kept:
`.gitmodules` entry is reset with the other files. Its directory then
counts as an untracked file.
### Hard restore
`X` restores to the **committed** pins, at every depth: what the commits
record, without any staged pin.
- The selected submodule goes to the committed pin, without the `s` / `c`
question; a staged pin of it is unstaged (as with `c`). A submodule that
was added but not committed yet has no committed pin and goes to the one
in the index.
- Every restored repository's index is reset to its `HEAD`, the superproject
included: its staged pins are dropped, and the submodules below follow the
committed tree.
So `x` and `X` differ in the staged pins of repositories that the restore
does not move: `x` (`s` or `c`) keeps them, `X` drops them. This matters
for the superproject, for a selected submodule without a staged pin of its
own, and for nested repositories that are on their pin. A repository that
moves loses its staged pins either way (see above); then `x` → `c` and `X`
end the same, but `c` asks first.
The dropped staged pins are counted in the loss (`2 staged pins`), the
selected submodule's own staged pin included, and like any loss they make
restore ask (D31): `Restore libs/staged to the committed pins? Discards 1
staged pin.` The
commits of a dropped pin stay in the submodule; if HEAD leaves them and no
ref reaches them, they count as `commits on no branch`, as always.
### Postconditions
After a successful restore of repository R with target T:
@@ -131,7 +160,7 @@ Restore discards work. The things it can lose:
| 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) |
| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)) |
| Staged pins | Staged pins in a repository that restore moves to another commit (see [Target](#target)); with `X` all staged pins |
| 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
@@ -213,8 +242,10 @@ is resolved like `Status` does, and the first candidate (see
accepts wins. The branch checked out before the restore is passed to the
ref resolution. A child's `.gitmodules` branch is read once per parent.
With `c` the parent's index gets the committed pin first (`update-index
--cacheinfo` in the parent).
With `c` and `X` the parent's index gets the committed pin first
(`update-index --cacheinfo` in the parent). With `X` (`RestoreHard`) the
staged pins are not put back, and the loss check takes the children's
targets from `HEAD`'s tree instead of the index.
`RestoreLoss` (`lossOf`) follows the repositories on disk: per repository
`status` (without submodules), the in-progress state files, and
@@ -258,9 +289,14 @@ For a **submodule**, with the tip `T` the list showed:
2. The submodule, and everything below it, is [restored](#restore) to `T`.
3. HEAD is put on the branch (the branch name is the local one):
- a local branch: checked out;
- a remote branch `origin/feat`: the local branch `feat` is created,
tracking it; if `feat` exists at `T` it is checked out; if it exists
**elsewhere** it is not moved and HEAD stays detached at `T`.
- a remote branch `origin/feat`: the local branch is the one checked out
before if it tracks `origin/feat` (e.g. `dev`), else `feat`. It is
created, tracking it, if it does not exist; if it exists at `T` it is
checked out; if it is only
**behind** `T` (an ancestor of it) it is fast-forwarded to `T` and
checked out ([D29](decisions.md)); if it has commits of its own, or is
checked out in another worktree, it is **not moved** and HEAD stays
detached at `T`.
For the **superproject** step 1 is left out: the branch is checked out via the
restore, so its submodules follow the pins of the new commit (staged pins of
@@ -279,7 +315,7 @@ only if something would be lost. A submodule that is not initialized can't be
switched (`src/app is not initialized; restore it first`). Network: only what
is missing below, like restore. Lines above the footer: `checking …`,
`switching src/app to origin/feat…`; flash `switched src/app to feat` (or
`… to origin/feat (detached: local feat is elsewhere)`); failure
`… to origin/feat (detached: local feat can't be fast-forwarded)`); failure
`switch of src/app to origin/feat failed: …`, until the next key. One
restore or switch runs at a time.
@@ -287,72 +323,81 @@ restore or switch runs at a time.
`git.Provider.Switch` (`internal/submodule/git/switch.go`): verify the ref is
still `T`; `update-index --cacheinfo` in the parent; `restoreRepo` (see
restore); then `switch` / `switch -c <local> --track <remote-branch>`. The restore leaves HEAD of the
restore); then `switch` / `switch -c <local> --track <remote-branch>`; a local branch
behind `T` is first moved with `merge-base --is-ancestor <local> T`, a look
at `worktree list --porcelain` and `update-ref refs/heads/<local> T <old>`
(which fails if the branch moved meanwhile). The restore leaves HEAD of the
switched repository alone (Switch puts it on the chosen branch); the
submodules below it are attached as in a restore.
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
`for-each-ref`, without the `origin/HEAD` symref.
## Stage pin
## Update 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.
Moves the pin of a submodule to the tip of its ref, the branch the row shows
(`origin/main ↓2`). It is a [switch](#switch-branch) to that branch, with the
tip the row shows: nothing is fetched, so `u` gives exactly the commits the
row counts. `f` is what brings newer ones.
On the **superproject** the pin is HEAD and the ref is the upstream of its
branch (the header's `main ↓1`): `u` fast-forwards the branch to it, like
`git pull --ff-only`, and restores the submodules to the pins of the new
commit.
### 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.
- it is initialized and its state is loaded;
- its ref is a remote branch (not a tag, a SHA or a local branch) and has a
tip; for the superproject: its branch has an upstream;
- the pin is **behind** the ref and not ahead (`Behind > 0`, `Ahead == 0`).
A diverged pin (`↑n↓m`) is left to `b`: moving it would drop the pin's
own commits from the record.
### 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: restore the submodule and choose `c` (committed pin), see
[Target](#target).
The same as [switching](#switch-branch) to the ref: the tip is staged
as the pin, the submodule and everything below it are restored to it, and
HEAD is put on the local branch. A local branch that is only behind is
fast-forwarded, so the common case (local `main` at the old pin) ends on
`main`. A checked-out branch that tracks the ref under another name (`dev`
tracking `origin/main`) is the one used, so the update stays on it. For the
superproject staged pins are lost, as in a switch (and counted).
If the ref no longer points at the tip the row showed (a fetch meanwhile),
nothing happens: `can't update src/app: origin/main moved; check the new
state`. The watcher has updated the row by then; `u` again takes the new
tip.
### Confirmation, lines, errors
As for switch: the question appears only if something would be lost
(`Update src/app to origin/main? Discards … y update · n cancel`). Lines
above the footer: `checking src/app…`, `updating src/app to origin/main…`;
flash `updated src/app to main`; failure `update of src/app to origin/main
failed: …`.
### 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.
The UI only: `startPinUpdate` (`internal/tui/pinupdate.go`) reads the
branches (`Switcher.Branches`), takes the one named like `Pin.Ref`, checks
that its tip still starts with the abbreviated `Status.Tip.SHA`, and then
runs the switch flow with it (`SwitchLoss`, `Switch`).
## Keys and palette
Both are submodule keys (`keyMap.Submodule`), shown in the detail pane's
palette, or in the footer when the pane can't show it. Restore is always
offered: it is idempotent, and the palette is static.
All three are submodule keys (`keyMap.Submodule`), shown in the detail
pane's palette, or in the footer when the pane can't show it. They are
always offered, the palette is static: restore is idempotent, and `u` says
why it can't update. Only `u`'s label follows the selection: on the header
it reads `pull (fast-forward)`.
| Key | Action | Pressed when not possible (flash) |
|---|---|---|
| `x` restore | Restore the selection | `this data source can't restore` (mock); `wait for the restore of … to finish` |
| `x` restore to pin, `X` restore committed pins | Restore the selection; `X` see [Hard restore](#hard-restore) | `this data source can't restore` (mock); `wait for the restore of … to finish` |
| `b` switch branch | [Switch branch](#switch-branch) | `this data source can't switch branches`; `wait for the restore of … to finish`; `src/app is not initialized; restore it first` |
| `s` stage pin | [Stage pin](#stage-pin), not implemented | `src/app is on its pin` / `src/app has uncommitted changes; commit or restore them first` / `the superproject has no pin` |
| `u` update pin (`pull (fast-forward)` on the header) | [Update pin](#update-pin) | `this data source can't update pins`; `wait for the restore of … to finish`; `the superproject has no upstream branch`; `src/app is still loading`; `src/app is not initialized; restore it first`; `the pin of src/app is not on a branch`; `the pin of src/app has diverged from origin/main; b switches the branch`; `the pin of src/app is ahead of origin/main`; `src/app is up to date with origin/main` (the superproject without `the pin of`); after the check `can't update src/app: main is a local branch; b switches to it` |
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
action, hence `x`.
@@ -376,12 +421,19 @@ action, hence `x`.
- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
with one (`y` restores, anything else cancels), the mock can't restore.
- Switch: provider (`switch_test.go`): list without `origin/HEAD`, local
first; a remote pick stages the tip and creates a tracking branch; an
existing local branch elsewhere is not moved; a moved ref aborts; the
first; a remote pick stages the tip and creates a tracking branch; a
local branch that is only behind is fast-forwarded; one with commits of
its own, or checked out in another worktree, is not moved; a moved ref aborts; the
superproject switch ends exact; the loss is the restore's. Picker
(`branches_test.go`): filter, selection, scrolling. UI (`switch_test.go`):
opens, filters, enter switches, asks with a loss, esc closes, the mock
can't.
- Update pin: UI (`pinupdate_test.go`): switches to the tip of the ref, asks
with a loss, aborts if the ref moved, refuses (with its flash) a pin that
is up to date, ahead, diverged, on a tag, on a local branch or not
initialized; updates the superproject, not one without an upstream.
Provider: the superproject's branch is fast-forwarded; a checked-out
branch that tracks the remote branch under another name is kept.
- By hand in tmux on a fixture, see
[architecture.md](architecture.md#testing).
@@ -392,7 +444,5 @@ submodule is restored to the index gitlink, so a staged pin is its target and
survives; restore may clone and fetch what is missing; the key is `x`; it
asks only if something would be lost.
Open (stage pin):
1. **Key.** `s`?
2. **Palette.** Show it always (like restore), or only while it applies?
Open (update pin): updating several submodules at once (on the superproject
or a collapsed parent), deliberately left out for now.
+3 -2
View File
@@ -20,7 +20,7 @@ internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
fetch.go Fetch (network), Fetchable
watch.go Watch: polls git metadata for changes
restore.go Restore, RestoreLoss (network: only what is missing)
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach the branch)
switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach or fast-forward the branch)
gitdir.go resolves .git dirs and gitdir: files
internal/submodule/mock/ fixture provider with artificial latency
internal/update/ self-update from the package registry; NO UI imports
@@ -28,6 +28,7 @@ internal/tui/ Bubble Tea application
app.go root model: layout, message routing, loading
keys.go key bindings (also feed the help view)
restore.go, switch.go restore / switch flows; a switch reuses restoreState
pinupdate.go update pin (u): a switch to the pin's ref, at the tip the row shows
overlay.go draws the picker over the finished view at the position the picker reports
msgs.go tree/status/fetch/change messages
refresh.go reloader: one load per key, coalesced reruns
@@ -36,7 +37,7 @@ internal/tui/ Bubble Tea application
detail/ detail pane; changes.go: change preview
header/ header line
branches/ branch picker popup (search line + list)
footer/ key bar (navigation left, global right) and flash messages
footer/ key bar (navigation and tools left, global right), full help pages (keys, legend) and flash messages
```
### Layering rules
+105 -1
View File
@@ -362,7 +362,7 @@ Enter on a branch of a submodule stages its tip as the pin in the parent's
index and restores the submodule to it (D23, recursion included); then HEAD
is put on the branch. On the superproject it checks the branch out and
restores below, without staging.
- *Key `b`*, not `s`, which stays reserved for stage pin.
- *Key `b`*, not `s`, which stays reserved for stage pin (dropped, D29).
- *Remote branch.* A local branch of that name is created, tracking it. If
one exists at another commit it is **not moved**: HEAD stays detached at
the tip and the flash says so (restore never moves branches, D23).
@@ -530,3 +530,107 @@ the pin, the ref is resolved (as for `Status`) and one `switch` runs. No
network. `detached HEAD on pin` is now left only where no branch is at the
pin. Switch keeps its own rule for the repository it switches; the
submodules below it follow this one.
## D29 — Update pin instead of stage pin; switch fast-forwards a branch behind
**Context.** Stage pin (record the checkout as the pin) was drafted, but a
commit made in a submodule is on a branch, and switching to that branch
(`b`) already stages it. The common job is a different one: a pin that is
behind its branch (`origin/main ↓2`) should move to the tip. With `b` that
is a search in the popup, and it often ended detached, because the local
`main` sat at the old pin and was never moved (D24).
**Decision.** `u` updates the pin of a submodule to the tip of its ref, only
when the pin is behind it and not ahead. It is a switch to that branch (D24)
with the tip the row shows: nothing is fetched, and if the ref moved
meanwhile nothing happens. Switch (and so `u`) now fast-forwards a local
branch that is an ancestor of the tip, unless a worktree has it checked out;
`update-ref` with the old tip makes the move fail if the branch moved. Stage
pin is dropped.
- *No fetch.* The user sees `↓2` and gets those two commits; a fetch first
would update to something the screen never showed. `f` is separate.
- *Only behind.* A diverged pin has commits the ref lacks; moving it would
drop them from the record, so that is a deliberate `b`.
- *The superproject too.* Its pin is HEAD and its ref the upstream, so `u`
there is `git pull --ff-only` plus a restore of the submodules. Switch
keeps a checked-out branch that tracks the chosen remote branch under
another name (`dev` tracking `origin/main`), so an update never changes
the branch.
- *One at a time.* Updating several at once is left for later.
- *Fast-forward only.* A local branch with commits of its own is never
moved, as before. Restore (D23, D28) still never moves a branch: it goes
back to the recorded pin, where moving branches has no place.
**Alternatives.** Keeping stage pin: it covers only a commit on no branch,
which then is `unpushed` anyway. `u` fetching first (a real pull): rejected,
see above. Resetting a local branch with commits of its own: loses them from
the branch.
**Consequences.** Amends D24 ("not moved") and D28's note that switch keeps
its own rule. No new provider interface: `u` is `Branches`, `SwitchLoss` and
`Switch`. Since `Status.Tip.SHA` is abbreviated, the UI compares it as a
prefix of the branch's full SHA.
## D30 — Hard restore drops staged pins at every depth
**Context.** `x` keeps staged pins at every depth (D23), except in a
repository the restore moves. Its `c` answer unstages only the selected
submodule's pin. Staged pins of the superproject, or of nested repositories
that stay on their pin, could only be dropped one by one.
**Decision.** Amends D23. `X` is a hard restore: the selected submodule goes
to its committed pin without the `s` / `c` question, and every restored
repository's index is reset to `HEAD` (the superproject's too), so all
staged pins below are dropped and the submodules follow the committed
trees. Dropped staged pins are counted in the loss and listed if the
question appears, but alone they don't make it appear. `x` and its question
stay as they are.
**Rationale.** A separate key keeps the common `x` free of a further
question and makes the stronger action deliberate. A staged pin is only a
recorded SHA; its commit stays in the submodule, and if HEAD leaves a
commit no ref reaches, `commits on no branch` still asks.
**Alternatives.** A third answer in the `s` / `c` question: only reachable
for a submodule with a staged pin itself, not for the superproject or a
clean parent. Dropping the question from `x`: rejected, the choice between
staged and committed pin is still useful there.
**Consequences.** `RestoreTarget` gets `RestoreHard`, which also matters for
the superproject. `restoreRepo` and `lossOf` take whether to keep staged
pins; switch always keeps them. Without a committed pin (an added
submodule), `X` uses the index pin.
## D31 — Hard restore asks before dropping staged pins
**Context.** D30 let `X` drop staged pins without a question: pressing `X`
was the choice. In a usability walkthrough (see
[notes/usability-plan.md](../notes/usability-plan.md)) `X` on `libs/staged`
unstaged its pin silently. `X` is one Shift away from `x`, and users new to
submodules don't know where to find the dropped commit again.
**Decision.** Amends D30. Dropped staged pins are a loss like any other and
make `X` ask: `Restore libs/staged to the committed pins? Discards 1 staged
pin.` `RestoreLoss` with `RestoreHard` now also counts the selected
submodule's own staged pin (in its parent's index), which `lossOf` doesn't
see. The `c` answer of `x` stays silent: the user picked it in a question.
**Consequences.** `X` asks more often, but only where it changes what is
recorded. Without staged pins or other losses it still runs at once.
## D32 — The screen explains itself
**Context.** The target user is a developer who doesn't know submodules
well and doesn't read manuals. The walkthrough found labels that don't say
what a key does (`x restore`, `X hard restore`), important keys found only
in `?` (`!`, `/`, `f`), and status words explained nowhere.
**Decision.**
- Labels name the target: `x restore to pin`, `X restore committed pins`.
"Hard" suggested "discards changes", which both do.
- A fourth key group, Tools (`!`, `/`, `f`), in the footer after the
navigation keys, dropped before them.
- On the header, `u` reads `pull (fast-forward)`: the label follows the
selection, the key doesn't.
- The full help gets titled columns, and a second page, a legend of the
status words, symbols and spine roles in their colors. Issue levels move
to `IssueKind.Level()` so the legend can't drift from the tree. `esc`
closes either page: cycling back with `?` alone was not obvious.
- A start hint above the footer until the first key.
- `fetched <age> · f fetch` in the detail pane when the last fetch is older
than a day.
**Alternatives.** A first-run screen or tutorial: needs state, and is
skipped. Contextual palettes that grey out impossible keys: larger, kept for
later (item 10 of the usability plan).
**Consequences.** `footer.Keys` drops `help.KeyMap` for `Tools`,
`HelpGroups` and `LegendKeys`; the footer renders the full help itself.
+11 -1
View File
@@ -148,6 +148,10 @@ commit hashes. It doesn't answer the questions users actually have:
staged or the committed pin (the latter unstages it). A restored submodule
whose pin is a branch tip ends on that branch, not detached. It asks for
confirmation only if something would be lost. See [actions.md](actions.md#restore).
`X` is a hard restore: it goes to the committed pins at every depth,
without the pin question, and drops all staged pins below the selection
too. Dropped staged pins count as a loss, so it asks first. See
[actions.md](actions.md#hard-restore).
- **F57** `b` opens a popup in the middle of the screen with a search line
and the local and remote branches of the selection (a submodule, or the
@@ -156,7 +160,13 @@ commit hashes. It doesn't answer the questions users actually have:
with everything below; HEAD ends on the branch. It asks for confirmation
only if something would be lost. See [actions.md](actions.md#switch-branch).
Stage pin is drafted in [actions.md](actions.md#stage-pin).
- **F59** `u` updates the pin of the selection to the tip of its ref, when
the pin is only behind it (`origin/main ↓2`): a switch (**F57**) to that
branch, with exactly the tip the row shows. For the superproject that is
a fast-forward of its branch to its upstream (`git pull --ff-only`). Nothing is fetched; if
the ref moved meanwhile, nothing happens. A local branch that is only
behind is fast-forwarded, in a switch too. Otherwise the footer says why
not. See [actions.md](actions.md#update-pin).
## Usage
+69 -19
View File
@@ -26,7 +26,7 @@
my_submodule_3 unpushed origin/main ↑1 │ ┊ 1 commit
libs/ │ tip ● 6f5e4d3 Release 2.1.0
logging origin/main ↓3 │
↑↓/jk move • ←→/hl fold • ⇧←→/HL fold all • space toggle • ? help • q quit
↑↓/jk move • ←→/hl collapse/expand • ⇧←→/HL all • space toggle • ! problems only • / filter • f fetch ? help • q quit
```
| Area | Height / width |
@@ -34,26 +34,56 @@
| 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 |
| Line above the footer | 1 line, only while filtering, showing problems only, during a restore, after a failure, or until the first key (hint) |
| Footer | 1 line (more with full help) |
### Key bar and palette
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
Keys come in four groups (`keyMap.Navigation`, `.Tools`, `.Submodule`,
`.Global`):
- **Navigation** (move, fold, toggle): left in the footer.
- **Navigation** (move, collapse/expand, toggle): left in the footer.
- **Tools** (`!` problems only, `/` filter, `f` fetch): in the footer after
the navigation keys, most useful first. They act on the whole list and
would otherwise be found only in the full help (D32).
- **Global** (`?`, `q`): right-aligned in the footer, always kept. If a newer
release exists, `q quit and update to vX` follows them as the right-most
item, on a highlighted background (`Theme.Highlight`: black on Primary), and
plain quit is `Q` (see D27).
- **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.
- **Submodule** (act on the selected one, e.g. `c`): a palette at the
bottom of the detail pane, wrapped onto a second line if the pane is too
narrow for all keys (none is cut off). 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 tools. With the header selected, `u`
reads `u pull (fast-forward)`, which is what it does there (D29).
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.
(submodule keys first, then the tools); the global keys stay. Keys work
whether or not they are shown.
### Full help
`?` steps through two pages, then back to the key bar; `esc` closes either
page at once (before it would close the filter):
1. **Keys**: every key in titled columns: `Move`, `Jump`, `Selected
submodule`, `All submodules`, `App`. The `App` column says `? what words
mean` and `esc close`.
2. **Legend**: what the words and symbols mean, in their colors: `Status`
(each issue word, `staged`, `in sync`), `Symbols` (`●`, `↑n ↓n`, `+n`,
`·k`, `[n]`), `Commits` (the spine's roles). Then `App` with `esc close`
(`?` closes it too).
The words come from `Theme.Legend()`; issue colors from
`IssueKind.Level()`, so they are the tree's.
Titles are muted and bold. Columns that don't fit are left out from the
right, marked with `…`; the `App` column is always kept.
### Start hint
Until the first key, the line above the footer reads muted `new here? ?
shows every key and what the words mean`. Every start, no state is kept.
Any other line above the footer (filter, restore, …) takes precedence.
### Tree row
@@ -84,7 +114,9 @@ For the superproject (header selected), the `pin` and `you` roles become
1. Directory (muted) and name (bold); `fetched <age>` of the submodule,
muted, right-aligned in the name's line (left out if the pane is too
narrow, or the submodule is missing)
narrow, or the submodule is missing). If the last fetch is older than a
day, or there was none, it continues `· f fetch` (key bold), if there is
room
2. One line per issue: word (colored, 11 columns) + explanation;
`in sync nothing to do` if there are none and no pin is staged;
`staged` and `detached HEAD on pin` as information
@@ -212,7 +244,7 @@ shows the superproject's own state word like a tree row (`in sync`,
`changed`, `unpushed +1`, spinner, `error`). The counts that follow are
about submodules only.
`path branch ↑↓ state n error · <count> <issue>… · n pin staged · n loading · n submodules · <fetch>`
`path branch ↑↓ state n error · <count> <issue>… · n staged · n loading · n submodules · <fetch>`
The summary and `<fetch>` form one group at the right edge; the path,
branch and state stay at the left. If the line is too narrow it is cut with
@@ -286,7 +318,8 @@ colors in components.
| `H` / `shift+←` / `ctrl+←` | Collapse all (resets remembered expansion); select the top-level ancestor |
| `home` / `g`, `end` / `G` | Header (superproject) / last submodule |
| `pgup`, `pgdn` | Move by one screen |
| `?` | Toggle full help |
| `?` | Full help: keys, then the legend, then closed, see [Full help](#full-help) |
| `esc` | Close the full help; else close the filter |
| `q`, `ctrl+c` | Quit. With a newer release: `q` quits and updates, `Q` and `ctrl+c` quit |
| `f` | Fetch all repositories (does nothing while a fetch runs) |
| `r` | Reload everything (e.g. after editing files) |
@@ -294,6 +327,7 @@ colors in components.
| `/` | Open the filter, see [Filter](#filter) |
| `!` | Toggle problems only, see [Problems only](#problems-only) |
| `x` | Restore the selection, see [Restore](#restore) |
| `X` | Restore the selection to the committed pins, staged pins dropped at every depth, see [Restore](#restore) |
"First child" always means the first child **as displayed** (children are
grouped by directory).
@@ -360,7 +394,10 @@ the term*.
| failed | red `restore of src/app failed: <git's message>`, until the next key |
`y` restores, any other key cancels; the keys are not passed on while a
question is open. After `c` the name reads `src/app to the committed pin`. Without a loss there is no question. Success flashes
question is open. After `c` the name reads `src/app to the committed pin`.
`X` (restore committed pins) never asks for the pin; its name reads
`src/app to the committed pins`. Staged pins it drops count as a loss
(`Discards 1 staged pin.`), so it asks (D31). Without a loss there is no question. Success flashes
`restored src/app` in the footer. For the header the name is `the
superproject`. This line takes precedence over the filter line and the
problems-only notice.
@@ -393,6 +430,16 @@ than `Muted`, so the popup has the focus.
[restore](#restore): `checking`, the question `Switch src/app to
origin/feat? Discards … y switch · n cancel`, `switching …`.
### Update pin
`u` runs without a popup, on the line above the footer like a
[switch](#switch-branch): `checking src/app…`, the question `Update src/app
to origin/main? Discards … y update · n cancel` only if something would be
lost, `updating src/app to origin/main…`, then the flash `updated src/app to
main`. On the header the name is `the superproject` (`updated the
superproject to main`). Where it can't update, a flash says why (see
[actions.md](actions.md#keys-and-palette)).
### Key reference
Every key in the UI, where it is shown. Update this
@@ -410,13 +457,16 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
| `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help |
| `pgup`, `pgdn` | Move by one screen | Navigation | Help |
| `c` | Copy path | Submodule | Palette, Help |
| `u` | Update pin to the tip of its ref (superproject: fast-forward to upstream, label `pull (fast-forward)`) | Submodule | Palette, Help |
| `b` | Switch branch (popup) | Submodule | Palette, Help |
| `x` | Restore | Submodule | Palette, Help |
| `f` | Fetch all repositories | Global | Help |
| `x` | Restore to pin | Submodule | Palette, Help |
| `X` | Restore committed pins | Submodule | Palette, Help |
| `!` | Problems only (toggle) | Tools | Bar, Help |
| `/` | Filter | Tools | Bar, Help |
| `f` | Fetch all repositories | Tools | Bar, Help |
| `r` | Reload everything | Global | Help |
| `/` | Filter | Global | Help |
| `!` | Problems only (toggle) | Global | Help |
| `?` | Toggle full help | Global | Bar, Help |
| `?` | Full help: keys, legend, close | Global | Bar, Help |
| `esc` | Close the full help (and the filter, see [Filter](#filter)) | Global | Help |
| `q`, `ctrl+c` | Quit (`Q`, `ctrl+c` while an update is offered) | Global | Bar, Help |
| `q` | Quit and update to the newer release (only if there is one) | Global | Bar (highlighted), Help |
+54 -24
View File
@@ -35,19 +35,39 @@ func (p *Provider) RestoreLoss(ctx context.Context, path string, to submodule.Re
if err != nil {
return submodule.Loss{}, err
}
if dir == "" { // not initialized: nothing on disk to lose
return submodule.Loss{}, nil
var loss submodule.Loss
if dir != "" { // not initialized: nothing on disk to lose
loss = p.lossOf(ctx, dir, target, to.KeepsStaged())
}
return p.lossOf(ctx, dir, target), nil
if to == submodule.RestoreHard && path != "" && p.pinStaged(ctx, path, target) {
// a hard restore unstages the submodule's own pin too
loss.StagedPins++
loss.Modules = max(loss.Modules, 1)
}
return loss, nil
}
// pinStaged reports whether the pin of path in its parent's index differs
// from committed, the pin a hard restore goes to.
func (p *Provider) pinStaged(ctx context.Context, path, committed string) bool {
p.mu.RLock()
in, ok := p.mods[path]
p.mu.RUnlock()
if !ok {
return false
}
staged, err := pinOf(ctx, in, submodule.RestoreStaged)
return err == nil && staged != committed
}
// Restore makes path ("" is the superproject) and all submodules below it
// exactly what git records, see docs/actions.md. Staged pins are kept. With
// RestoreCommitted, a submodule's own staged pin is unstaged first. A missing
// submodule is cloned and missing commits are fetched; git never prompts.
// RestoreCommitted, a submodule's own staged pin is unstaged first; with
// RestoreHard, the staged pins below are dropped too. A missing submodule is
// cloned and missing commits are fetched; git never prompts.
func (p *Provider) Restore(ctx context.Context, path string, to submodule.RestoreTarget) error {
if path == "" {
return p.restoreRepo(ctx, p.root, "HEAD", nil)
return p.restoreRepo(ctx, p.root, "HEAD", nil, to.KeepsStaged())
}
p.mu.RLock()
in, ok := p.mods[path]
@@ -59,7 +79,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
if err != nil {
return err
}
if to == submodule.RestoreCommitted {
if to.Committed() {
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+pin+","+in.relPath); err != nil {
return err
}
@@ -69,7 +89,7 @@ func (p *Provider) Restore(ctx context.Context, path string, to submodule.Restor
return fmt.Errorf("%s: %w", path, err)
}
}
return p.restoreRepo(ctx, in.dir, pin, &in)
return p.restoreRepo(ctx, in.dir, pin, &in, to.KeepsStaged())
}
// restoreTarget returns the directory and the commit path is restored to:
@@ -96,9 +116,10 @@ func (p *Provider) restoreTarget(ctx context.Context, path string, to submodule.
}
// pinOf returns the gitlink of a submodule in its parent's index (staged)
// or HEAD (committed).
// or HEAD (committed). A hard restore of a submodule that was added but not
// committed yet uses the index: there is no staged pin to drop.
func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, error) {
if to == submodule.RestoreCommitted {
if to.Committed() {
out, _, err := try(ctx, in.superDir, "ls-tree", "HEAD", "--", in.relPath)
if err != nil {
return "", err
@@ -106,7 +127,9 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
if pin := parseGitlink(out); pin != "" {
return pin, nil
}
return "", errors.New("has no committed pin (added, but not committed yet)")
if to != submodule.RestoreHard {
return "", errors.New("has no committed pin (added, but not committed yet)")
}
}
out, err := run(ctx, in.superDir, "ls-files", "-s", "--", in.relPath)
if err != nil {
@@ -124,10 +147,11 @@ func pinOf(ctx context.Context, in info, to submodule.RestoreTarget) (string, er
// restoreRepo restores the initialized repository at dir to target (a commit
// or "HEAD") and then every submodule its index records. Branches are never
// moved: a checkout that is not on target is detached first. If the checkout
// stays on target, its staged pins are kept and are the submodules' targets.
// stays on target and keep is set, its staged pins are kept and are the
// submodules' targets; else the submodules follow target's tree.
// For a submodule (in is not nil) a detached HEAD is then put on a branch at
// target if there is one, see attachBranch; the submodules below always are.
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info) error {
func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info, keep bool) error {
env := p.networkEnv()
g := func(args ...string) (string, error) {
return p.exec(ctx, dir, env, append(append([]string{}, restoreArgs...), args...)...)
@@ -143,18 +167,20 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info
p.clearOperations(ctx, dir, gitDir)
}
prev, _, _ := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD") // for the ref, see attachBranch
var keep []gitlink
var kept []gitlink
if head, err := g("rev-parse", "--verify", "-q", "HEAD"); err != nil || head != sha {
if _, err := g("checkout", "--force", "--detach", sha); err != nil {
return err
}
} else if keep, err = stagedPins(ctx, dir); err != nil {
return err
} else if keep {
if kept, err = stagedPins(ctx, dir); err != nil {
return err
}
}
if _, err := g("reset", "--hard", "-q"); err != nil {
return err
}
for _, l := range keep {
for _, l := range kept {
if _, err := g("update-index", "--cacheinfo", "160000,"+l.sha+","+l.path); err != nil {
return err
}
@@ -206,7 +232,7 @@ func (p *Provider) restoreRepo(ctx context.Context, dir, target string, in *info
go func() {
defer wg.Done()
child := info{superDir: dir, dir: filepath.Join(dir, filepath.FromSlash(l.path)), relPath: l.path, branch: branches[l.path]}
if err := p.restoreRepo(ctx, child.dir, l.sha, &child); err != nil {
if err := p.restoreRepo(ctx, child.dir, l.sha, &child, keep); err != nil {
errs[i] = err
}
}()
@@ -482,8 +508,9 @@ func stagedPins(ctx context.Context, dir string) ([]gitlink, error) {
// lossOf adds up what a restore of the repository at dir to target
// discards, including the submodules on disk below it. target is "" if it is
// not known; then commits are only judged by the refs that reach them.
func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Loss {
// not known; then commits are only judged by the refs that reach them. keep
// is false for a hard restore, which drops all staged pins.
func (p *Provider) lossOf(ctx context.Context, dir, target string, keep bool) submodule.Loss {
var own submodule.Loss
var children []gitlink
var targets map[string]string
@@ -545,16 +572,19 @@ func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Los
}
// staged pins are kept, unless the checkout moves away from
// the commit they were staged on; then the submodules follow
// the target's tree
// the commit they were staged on or the restore is hard; then
// the submodules follow the target's tree
staged, err := stagedPins(ctx, dir)
if err != nil {
unknown()
}
var links []gitlink
switch {
case !moves:
case !moves && keep:
links, err = indexGitlinks(ctx, dir)
case !moves:
own.StagedPins = len(staged)
links, err = treeGitlinks(ctx, dir, "HEAD")
case target != "":
own.StagedPins = len(staged)
links, err = treeGitlinks(ctx, dir, target)
@@ -589,7 +619,7 @@ func (p *Provider) lossOf(ctx context.Context, dir, target string) submodule.Los
wg.Add(1)
go func() {
defer wg.Done()
results[i] = p.lossOf(ctx, sub, targets[c.path])
results[i] = p.lossOf(ctx, sub, targets[c.path], keep)
}()
}
wg.Wait()
+77
View File
@@ -310,6 +310,83 @@ func TestRestoreCommittedUnstagesThePin(t *testing.T) {
}
}
func TestRestoreHardDropsStagedPinsAtEveryDepth(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
log := filepath.Join(super, "libs", "log")
nested := filepath.Join(log, "modules", "nested")
committed := out(t, nested, "rev-parse", "HEAD")
base, err := p.RestoreLoss(context.Background(), "", submodule.RestoreHard)
if err != nil {
t.Fatal(err)
}
// stage a new pin of nested in libs/log, on a commit only nested's
// detached HEAD reaches
git(t, nested, "checkout", "-q", "--detach")
git(t, nested, "commit", "-q", "--allow-empty", "-m", "Nested work")
git(t, log, "add", "modules/nested")
l, err := p.RestoreLoss(context.Background(), "", submodule.RestoreHard)
if err != nil {
t.Fatal(err)
}
if base.StagedPins == 0 || l.StagedPins != base.StagedPins+1 || l.Commits != base.Commits+1 {
t.Errorf("loss %+v, want the fixture's %d staged pins, the nested one and its commit", l, base.StagedPins)
}
if err := p.Restore(context.Background(), "", submodule.RestoreHard); err != nil {
t.Fatal(err)
}
for _, dir := range []string{super, log} {
if got := out(t, dir, "diff", "--cached", "--name-only"); got != "" {
t.Errorf("%s still staged: %q", dir, got)
}
}
if got := out(t, nested, "rev-parse", "HEAD"); got != committed {
t.Errorf("nested HEAD = %s, want the committed pin %s", got, committed)
}
assertExact(t, super)
}
func TestRestoreHardOfASubmoduleUnstagesItsPin(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
committed := strings.Fields(out(t, super, "ls-tree", "HEAD", "libs/staged"))[2]
if err := p.Restore(context.Background(), "libs/staged", submodule.RestoreHard); err != nil {
t.Fatal(err)
}
if got := out(t, super, "diff", "--cached", "--name-only"); strings.Contains(got, "libs/staged") {
t.Errorf("still staged: %q", got)
}
if got := out(t, filepath.Join(super, "libs", "staged"), "rev-parse", "HEAD"); got != committed {
t.Errorf("HEAD = %s, want the committed pin %s", got, committed)
}
}
func TestRestoreLossOfAHardRestoreCountsTheOwnStagedPin(t *testing.T) {
p, _ := ownFixture(t)
committed, err := p.RestoreLoss(context.Background(), "libs/staged", submodule.RestoreCommitted)
if err != nil {
t.Fatal(err)
}
hard, err := p.RestoreLoss(context.Background(), "libs/staged", submodule.RestoreHard)
if err != nil {
t.Fatal(err)
}
if hard.StagedPins != committed.StagedPins+1 || hard.Modules == 0 {
t.Errorf("hard loss %+v, want the own staged pin on top of %+v", hard, committed)
}
// without a staged pin, a hard restore unstages nothing of its own
clean, err := p.RestoreLoss(context.Background(), "src/ui", submodule.RestoreHard)
if err != nil {
t.Fatal(err)
}
if clean.StagedPins != 0 {
t.Errorf("loss of src/ui %+v, want no staged pin", clean)
}
}
func TestRestoreLossCountsStagedPinsOfAMovingRepository(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
+53 -10
View File
@@ -85,13 +85,16 @@ func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Bran
if err != nil {
return submodule.Loss{}, err
}
return p.lossOf(ctx, dir, b.SHA), nil
return p.lossOf(ctx, dir, b.SHA, true), nil
}
// Switch makes b's tip the staged pin of a submodule, restores the repository
// to it (see Restore) and puts HEAD on the branch. For a remote branch the
// local branch of that name is created, tracking it. A local branch that
// exists elsewhere is never moved: HEAD stays detached and "" is returned.
// local branch is the one checked out before if it tracks b, else the one
// of that name, created tracking b if there is none. A local branch that is
// only behind the tip is fast-forwarded; one with commits of its own, or
// checked out in another worktree, is never moved: HEAD stays detached and
// "" is returned.
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
dir, in, sub, err := p.switchDir(path)
if err != nil {
@@ -102,12 +105,16 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
} else if !ok || sha != b.SHA {
return "", fmt.Errorf("%s moved; open the list again", b.Name)
}
prev, _, err := try(ctx, dir, "symbolic-ref", "--short", "-q", "HEAD")
if err != nil {
return "", err
}
if sub { // staged first, so a failed restore can be repeated with x
if _, err := p.exec(ctx, in.superDir, nil, "update-index", "--cacheinfo", "160000,"+b.SHA+","+in.relPath); err != nil {
return "", err
}
}
if err := p.restoreRepo(ctx, dir, b.SHA, nil); err != nil { // HEAD goes on b below
if err := p.restoreRepo(ctx, dir, b.SHA, nil, true); err != nil { // HEAD goes on b below
return "", err
}
@@ -118,15 +125,51 @@ func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch)
_, err := g("switch", "-q", b.Local)
return b.Local, err
}
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+b.Local); {
local := b.Local
if prev != "" && prev != local {
if up, ok, err := upstream(ctx, dir, prev); err != nil {
return "", err
} else if ok && up == b.Name {
local = prev // e.g. dev tracking origin/main: an update stays on dev
}
}
switch tip, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", "refs/heads/"+local); {
case err != nil:
return "", err
case !ok:
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
return b.Local, err
_, err := g("switch", "-q", "-c", local, "--track", b.Name)
return local, err
case tip == b.SHA:
_, err := g("switch", "-q", b.Local)
return b.Local, err
_, err := g("switch", "-q", local)
return local, err
default:
if ok, err := p.fastForward(ctx, dir, local, tip, b.SHA); err != nil || !ok {
return "", err
}
_, err := g("switch", "-q", local)
return local, err
}
return "", nil
}
// fastForward moves the local branch from tip to sha if that loses nothing:
// tip is an ancestor of sha and no worktree has the branch checked out. It
// reports whether the branch was moved; a branch that moved meanwhile is
// left alone.
func (p *Provider) fastForward(ctx context.Context, dir, local, tip, sha string) (bool, error) {
if _, ok, err := try(ctx, dir, "merge-base", "--is-ancestor", tip, sha); err != nil || !ok {
return false, err
}
wts, err := run(ctx, dir, "worktree", "list", "--porcelain")
if err != nil {
return false, err
}
for _, line := range strings.Split(wts, "\n") {
if line == "branch refs/heads/"+local {
return false, nil
}
}
if _, ok, err := try(ctx, dir, "update-ref", "-m", "submarine: fast-forward", "refs/heads/"+local, sha, tip); err != nil || !ok {
return false, err
}
return true, nil
}
+111 -2
View File
@@ -81,12 +81,15 @@ func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
assertExact(t, sub)
}
func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
func TestSwitchNeverMovesALocalBranchWithCommitsOfItsOwn(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/main") // same name, elsewhere
// same name, diverged: a commit of its own on top of origin/main
git(t, sub, "switch", "-q", "-c", "feat/search", "origin/main")
git(t, sub, "commit", "-q", "--allow-empty", "-m", "own")
git(t, sub, "switch", "-q", "--detach", "HEAD~1")
before := out(t, sub, "rev-parse", "feat/search")
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
@@ -111,6 +114,112 @@ func TestSwitchNeverMovesAnExistingLocalBranch(t *testing.T) {
}
}
func TestSwitchFastForwardsALocalBranchThatIsBehind(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/feat/search~1") // behind only
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
b := findBranch(t, bs, "origin/feat/search")
got, err := p.Switch(ctx, "libs/feature", b)
if err != nil {
t.Fatal(err)
}
if got != "feat/search" || out(t, sub, "branch", "--show-current") != "feat/search" {
t.Errorf("Switch = %q, branch = %q; want feat/search", got, out(t, sub, "branch", "--show-current"))
}
if tip := out(t, sub, "rev-parse", "feat/search"); tip != b.SHA {
t.Errorf("feat/search = %s, want fast-forwarded to %s", tip, b.SHA)
}
assertExact(t, sub)
}
func TestSwitchSuperprojectFastForwardsItsBranch(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
// main, checked out, is one behind origin/main: a pull --ff-only
tip := out(t, super, "rev-parse", "HEAD")
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
git(t, super, "update-ref", "refs/heads/main", tip+"~1")
bs, err := p.Branches(ctx, "")
if err != nil {
t.Fatal(err)
}
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
if err != nil {
t.Fatal(err)
}
if got != "main" || out(t, super, "branch", "--show-current") != "main" {
t.Errorf("Switch = %q, branch = %q; want main", got, out(t, super, "branch", "--show-current"))
}
if head := out(t, super, "rev-parse", "main"); head != tip {
t.Errorf("main = %s, want fast-forwarded to %s", head, tip)
}
assertExact(t, super)
}
func TestSwitchStaysOnTheCheckedOutBranchThatTracksIt(t *testing.T) {
p, root := ownFixture(t)
super := filepath.Join(root, "super")
ctx := context.Background()
// dev, checked out, tracks origin/main and is one behind it
tip := out(t, super, "rev-parse", "HEAD")
git(t, super, "remote", "add", "origin", filepath.Join(root, "nowhere")) // never contacted
git(t, super, "update-ref", "refs/remotes/origin/main", tip)
git(t, super, "switch", "-q", "-c", "dev")
git(t, super, "update-ref", "refs/heads/dev", tip+"~1")
git(t, super, "branch", "-q", "--set-upstream-to=origin/main", "dev")
bs, err := p.Branches(ctx, "")
if err != nil {
t.Fatal(err)
}
got, err := p.Switch(ctx, "", findBranch(t, bs, "origin/main"))
if err != nil {
t.Fatal(err)
}
if got != "dev" || out(t, super, "branch", "--show-current") != "dev" {
t.Errorf("Switch = %q, branch = %q; want dev", got, out(t, super, "branch", "--show-current"))
}
if head := out(t, super, "rev-parse", "dev"); head != tip {
t.Errorf("dev = %s, want fast-forwarded to %s", head, tip)
}
if main := out(t, super, "rev-parse", "main"); main != tip {
t.Errorf("main = %s, must not have moved from %s", main, tip)
}
assertExact(t, super)
}
func TestSwitchNeverMovesABranchOfAnotherWorktree(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
ctx := context.Background()
git(t, sub, "branch", "feat/search", "origin/feat/search~1")
git(t, sub, "worktree", "add", "-q", filepath.Join(root, "wt"), "feat/search")
before := out(t, sub, "rev-parse", "feat/search")
bs, err := p.Branches(ctx, "libs/feature")
if err != nil {
t.Fatal(err)
}
got, err := p.Switch(ctx, "libs/feature", findBranch(t, bs, "origin/feat/search"))
if err != nil {
t.Fatal(err)
}
if got != "" {
t.Errorf("Switch returned %q, want detached", got)
}
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
t.Errorf("branch of the other worktree moved: %s -> %s", before, after)
}
}
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
p, root := ownFixture(t)
sub := filepath.Join(root, "super", "libs", "feature")
+11 -2
View File
@@ -72,15 +72,24 @@ type Switcher interface {
Switch(ctx context.Context, path string, b Branch) (string, error)
}
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
// the superproject, whose target is always HEAD.
// RestoreTarget is the pin a submodule is restored to, and whether staged
// pins below it are kept. The superproject's target is always HEAD; only
// RestoreHard matters for it.
type RestoreTarget int
const (
RestoreStaged RestoreTarget = iota // the pin in the parent's index: a staged pin is kept and used
RestoreCommitted // the pin in the parent's HEAD: a staged pin is unstaged
RestoreHard // the pin in the parent's HEAD, and staged pins below are dropped too
)
// Committed reports whether a submodule goes to the pin in its parent's HEAD.
func (t RestoreTarget) Committed() bool { return t != RestoreStaged }
// KeepsStaged reports whether staged pins below the restored repository are
// kept.
func (t RestoreTarget) KeepsStaged() bool { return t != RestoreHard }
// Loss is what a restore discards. The zero Loss means nothing is lost.
type Loss struct {
Files int // changed, conflicted and untracked files and directories
+23 -8
View File
@@ -24,6 +24,21 @@ const (
IssueOffTarget // pin pushed, but not on the target branch
)
// Kinds lists every issue kind, most severe first.
var Kinds = []IssueKind{IssueUnpushed, IssueConflict, IssueMissing, IssueOffPin, IssueChanged, IssueOffTarget}
// Level is how much attention an issue of kind k deserves.
func (k IssueKind) Level() Level {
switch k {
case IssueUnpushed, IssueConflict:
return LevelDanger
case IssueOffTarget:
return LevelInfo
default:
return LevelWarn
}
}
// Issue is one problem of a submodule. Detail distinguishes the states that
// are summarized into one kind.
type Issue struct {
@@ -40,9 +55,9 @@ func (s *Status) Issues() []Issue {
}
switch s.Presence {
case NotInitialized:
return []Issue{{IssueMissing, LevelWarn, "not initialized"}}
return []Issue{{IssueMissing, IssueMissing.Level(), "not initialized"}}
case DirMissing:
return []Issue{{IssueMissing, LevelWarn, "directory missing"}}
return []Issue{{IssueMissing, IssueMissing.Level(), "directory missing"}}
}
var out []Issue
@@ -51,24 +66,24 @@ func (s *Status) Issues() []Issue {
if s.Root {
detail = "HEAD not pushed to any remote"
}
out = append(out, Issue{IssueUnpushed, LevelDanger, detail})
out = append(out, Issue{IssueUnpushed, IssueUnpushed.Level(), detail})
}
if s.Worktree == WorktreeConflict {
out = append(out, Issue{IssueConflict, LevelDanger, "merge conflict"})
out = append(out, Issue{IssueConflict, IssueConflict.Level(), "merge conflict"})
}
if s.Checkout == CheckoutDiffers {
out = append(out, Issue{IssueOffPin, LevelWarn, "checkout differs from pin"})
out = append(out, Issue{IssueOffPin, IssueOffPin.Level(), "checkout differs from pin"})
}
switch s.Worktree {
case WorktreeModified:
out = append(out, Issue{IssueChanged, LevelWarn, "uncommitted changes"})
out = append(out, Issue{IssueChanged, IssueChanged.Level(), "uncommitted changes"})
case WorktreeUntracked:
out = append(out, Issue{IssueChanged, LevelWarn, s.untrackedText()})
out = append(out, Issue{IssueChanged, IssueChanged.Level(), s.untrackedText()})
}
// a pin that is pushed, but not on the target (usually main); an
// unpushed pin is not on it either, but has the worse issue
if !s.Root && s.Pin.Pushed && s.Pin.Target != "" && s.Pin.Ref != s.Pin.Target {
out = append(out, Issue{IssueOffTarget, LevelInfo, s.offTargetText()})
out = append(out, Issue{IssueOffTarget, IssueOffTarget.Level(), s.offTargetText()})
}
return out
}
+29 -7
View File
@@ -60,6 +60,7 @@ type Model struct {
picker *branches.Model // the branch picker, nil if closed
pickerPath string // the repository the picker is for
notice string // an error to show above the footer until the next key
hint bool // the hint for newcomers above the footer, until the first key
checkUpdate func(context.Context) string // looks for a newer release, nil if off
update string // the newer release found, "" if none
@@ -104,6 +105,7 @@ func New(ctx context.Context, p submodule.Provider, opts ...Option) Model {
footer: footer.New(t, k),
spinner: sp,
filter: fi,
hint: true,
}
m.fetcher, _ = p.(submodule.Fetcher)
m.watcher, _ = p.(submodule.Watcher)
@@ -385,14 +387,17 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m.answerRestore(msg)
}
}
if m.notice != "" {
m.notice = ""
if m.notice != "" || m.hint {
m.notice, m.hint = "", false
m.layout()
}
if m.filter.Focused() {
return m.typeFilter(msg)
}
switch {
case m.footer.HelpOpen() && msg.Type == tea.KeyEsc: // before the filter's esc
m.footer.CloseHelp()
m.layout()
case m.filtering && msg.Type == tea.KeyEsc:
m.closeFilter()
case key.Matches(msg, k.Quit):
@@ -401,7 +406,7 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.updateTo = m.update
return m, tea.Quit
case key.Matches(msg, k.Help):
m.footer.ToggleHelp()
m.footer.CycleHelp()
m.layout()
case key.Matches(msg, k.Up):
m.tree.Up()
@@ -448,9 +453,13 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.layout()
return m, m.filter.Focus()
case key.Matches(msg, k.Restore):
return m.startRestore()
return m.startRestore(false)
case key.Matches(msg, k.HardRestore):
return m.startRestore(true)
case key.Matches(msg, k.Switch):
return m.startSwitch()
case key.Matches(msg, k.Update):
return m.startPinUpdate()
case key.Matches(msg, k.Issues):
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
m.layout()
@@ -513,10 +522,16 @@ func (m Model) filterView() string {
}
// extraLine reports whether a line above the footer is shown: a restore in
// progress, a failure, the filter, or the notice that only problems are
// shown.
// progress, a failure, the filter, the notice that only problems are shown,
// or the hint for newcomers.
func (m Model) extraLine() bool {
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != ""
return m.filtering || m.tree.ProblemsOnly() || m.restore != nil || m.notice != "" || m.hint
}
// hintView is the line above the footer until the first key: where help is.
func (m Model) hintView() string {
muted := theme.Fg(m.theme.Muted)
return m.line(muted.Render("new here? ")+theme.Fg(m.theme.Text).Bold(true).Render("?")+muted.Render(" shows every key and what the words mean"), "")
}
// problemsView is the line above the footer while only problems are shown, so
@@ -577,6 +592,11 @@ func (m Model) View() string {
return theme.Fg(m.theme.Danger).Render("error: " + m.err.Error())
}
// the keys as they read for the selection; m is a copy
k := m.keys.forSelection(m.tree.RootSelected())
m.footer.SetKeys(k)
m.detail.SetActions(k.Submodule())
body := m.tree.View()
if m.showDetail {
pane := lipgloss.NewStyle().
@@ -597,6 +617,8 @@ func (m Model) View() string {
parts = append(parts, m.filterView())
} else if m.tree.ProblemsOnly() {
parts = append(parts, m.problemsView())
} else if m.hint {
parts = append(parts, m.hintView())
}
view := zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
if m.picker != nil { // after zone.Scan, so no zone marker is cut
+44 -9
View File
@@ -6,6 +6,7 @@ import (
"path"
"slices"
"strings"
"time"
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/bubbles/key"
@@ -18,6 +19,9 @@ import (
const labelWidth = 11
// staleFetch is the age of the last fetch from which the pane suggests f.
const staleFetch = 24 * time.Hour
type Model struct {
theme theme.Theme
width int
@@ -38,6 +42,9 @@ func New(t theme.Theme, actions []key.Binding) Model {
// PaletteVisible reports whether the palette of submodule actions fits.
func (m Model) PaletteVisible() bool { return m.height >= paletteMinHeight }
// SetActions replaces the bindings of the palette.
func (m *Model) SetActions(actions []key.Binding) { m.actions = actions }
func (m *Model) SetSize(w, h int) { m.width, m.height = w, h }
func (m *Model) SetSpinner(frame string) { m.spinner = frame }
@@ -55,20 +62,41 @@ func (m Model) View(s *submodule.Submodule, repo *submodule.Repo) string {
lines[i] = ansi.Truncate(l, m.width, "…")
}
height := m.height
var palette string
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, "…")
palette = m.palette()
height -= len(palette) // the palette is at the bottom
}
if len(lines) > height {
lines = lines[:height]
}
body := lipgloss.NewStyle().Width(m.width).Height(height).Render(strings.Join(lines, "\n"))
if palette == "" {
if len(palette) == 0 {
return body
}
return body + "\n" + palette
return body + "\n" + strings.Join(palette, "\n")
}
// palette lays the actions out on as many lines as the width needs, so no
// key is cut off.
func (m Model) palette() []string {
m.help.Width = 0
var out []string
var cur []key.Binding
for _, b := range m.actions {
if len(cur) > 0 && lipgloss.Width(m.help.ShortHelpView(append(cur, b))) > m.width {
out = append(out, m.help.ShortHelpView(cur))
cur = nil
}
cur = append(cur, b)
}
if len(cur) > 0 {
out = append(out, m.help.ShortHelpView(cur))
}
for i, l := range out {
out[i] = ansi.Truncate(l, m.width, "…")
}
return out
}
func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
@@ -102,10 +130,17 @@ func (m Model) lines(s *submodule.Submodule, repo *submodule.Repo) []string {
st := s.Status
// the age of the last fetch is right-aligned in the name's line
// with a hint to fetch if the last fetch is old, and room for it
if st.Presence == submodule.PresenceOK {
fetched := "fetched " + theme.Ago(st.FetchedAt)
if gap := m.width - ansi.StringWidth(s.Name) - len(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + muted.Render(fetched)
fetched := muted.Render("fetched " + theme.Ago(st.FetchedAt))
if time.Since(st.FetchedAt) > staleFetch {
hinted := fetched + muted.Render(" · ") + text.Bold(true).Render("f") + muted.Render(" fetch")
if m.width-ansi.StringWidth(s.Name)-lipgloss.Width(hinted) >= 2 {
fetched = hinted
}
}
if gap := m.width - ansi.StringWidth(s.Name) - lipgloss.Width(fetched); gap >= 2 {
out[1] += strings.Repeat(" ", gap) + fetched
}
}
+43
View File
@@ -0,0 +1,43 @@
package detail
import (
"strings"
"testing"
"time"
"github.com/charmbracelet/x/ansi"
"submarine/internal/submodule"
"submarine/internal/tui/theme"
)
func TestFetchHintForOldFetches(t *testing.T) {
m := New(theme.Default(), nil)
m.SetSize(60, 20)
for _, c := range []struct {
fetched time.Time
hint bool
}{
{time.Time{}, true},
{time.Now().Add(-48 * time.Hour), true},
{time.Now().Add(-time.Hour), false},
} {
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{
Presence: submodule.PresenceOK, FetchedAt: c.fetched, Pin: submodule.PinInfo{Pushed: true},
}}
got := ansi.Strip(m.View(s, nil))
if strings.Contains(got, "f fetch") != c.hint {
t.Errorf("fetched %s: hint %v, want %v:\n%s", theme.Ago(c.fetched), !c.hint, c.hint, got)
}
if !strings.Contains(got, "fetched "+theme.Ago(c.fetched)) {
t.Errorf("the age is missing:\n%s", got)
}
}
// too narrow for the hint: the age alone
m.SetSize(24, 20)
s := &submodule.Submodule{Path: "src/app", Name: "app", Status: &submodule.Status{Presence: submodule.PresenceOK}}
if got := ansi.Strip(m.View(s, nil)); strings.Contains(got, "f fetch") || !strings.Contains(got, "fetched never") {
t.Errorf("narrow pane:\n%s", got)
}
}
+41
View File
@@ -0,0 +1,41 @@
package detail
import (
"strings"
"testing"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
"submarine/internal/tui/theme"
)
func TestPaletteWrapsInsteadOfCuttingKeys(t *testing.T) {
keys := []key.Binding{
key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")),
}
m := New(theme.Default(), keys)
m.SetSize(36, 20)
lines := m.palette()
if len(lines) != 2 {
t.Fatalf("want 2 lines, got %q", lines)
}
all := strings.Join(lines, "\n")
for _, want := range []string{"copy path", "update pin", "switch branch", "x restore to pin"} {
if !strings.Contains(all, want) {
t.Errorf("palette lacks %q: %q", want, lines)
}
}
for _, l := range lines {
if w := lipgloss.Width(l); w > 36 {
t.Errorf("line %q is %d wide", l, w)
}
}
m.SetSize(120, 20)
if lines := m.palette(); len(lines) != 1 {
t.Errorf("wide pane: want 1 line, got %q", lines)
}
}
+132 -11
View File
@@ -2,6 +2,7 @@
package footer
import (
"slices"
"strings"
"time"
@@ -18,18 +19,39 @@ const flashDuration = 2 * time.Second
type clearFlashMsg struct{ id int }
// Keys provides the key groups. The short bar shows Navigation (and
// Keys provides the key groups. The short bar shows Navigation, Tools (and
// Submodule, if requested) on the left, Global and then Highlight (on a
// highlighted background) on the right; full help
// shows FullHelp.
// highlighted background) on the right. The full help has two pages: the
// HelpGroups (the last one is always kept), then the theme's legend with
// LegendKeys next to it.
type Keys interface {
help.KeyMap
Navigation() []key.Binding
Tools() []key.Binding
Submodule() []key.Binding
Global() []key.Binding
Highlight() []key.Binding
HelpGroups() []Group
LegendKeys() Group
}
// Group is a titled column of the full help.
type Group struct {
Title string
Keys []key.Binding
}
// helpPage is what the footer shows besides a flash.
type helpPage int
const (
pageBar helpPage = iota // one line of key hints
pageKeys // every key, in titled columns
pageLegend // what the words and symbols mean
)
// columnGap separates the columns of the full help.
const columnGap = " "
type Model struct {
theme theme.Theme
help help.Model
@@ -37,6 +59,7 @@ type Model struct {
width int
flash string
flashID int
page helpPage
actions bool // show the submodule bindings in the short bar
}
@@ -56,8 +79,18 @@ func (m *Model) SetWidth(w int) {
m.help.Width = w - 2
}
// ToggleHelp switches between short and full key help.
func (m *Model) ToggleHelp() { m.help.ShowAll = !m.help.ShowAll }
// CycleHelp shows the next help page: the short bar, every key, the legend,
// then the short bar again.
func (m *Model) CycleHelp() { m.page = (m.page + 1) % 3 }
// HelpOpen reports whether a full help page is shown.
func (m Model) HelpOpen() bool { return m.page != pageBar }
// CloseHelp goes back to the short bar from any help page.
func (m *Model) CloseHelp() { m.page = pageBar }
// Legend reports whether the legend is shown.
func (m Model) Legend() bool { return m.page == pageLegend }
// Flash shows msg for a short time instead of the key hints.
func (m *Model) Flash(msg string) tea.Cmd {
@@ -78,14 +111,102 @@ func (m Model) View() string {
if m.flash != "" {
return style.Foreground(m.theme.Warn).Render(m.flash)
}
if m.help.ShowAll {
return style.Render(m.help.View(m.keys))
switch m.page {
case pageKeys:
var cols []column
for _, g := range m.keys.HelpGroups() {
cols = append(cols, m.keyColumn(g))
}
return style.Render(m.columns(cols[:len(cols)-1], cols[len(cols)-1], m.width-2))
case pageLegend:
var cols []column
for _, g := range m.theme.Legend() {
c := column{title: g.Title}
for _, r := range g.Rows {
c.rows = append(c.rows, [2]string{r.Word, m.help.Styles.FullDesc.Render(r.Meaning)})
}
cols = append(cols, c)
}
return style.Render(m.columns(cols, m.keyColumn(m.keys.LegendKeys()), m.width-2))
}
return style.Render(m.shortBar(m.width - 2))
}
// column is a titled column of the full help: rows of a rendered key (or
// word) and its description.
type column struct {
title string
rows [][2]string
}
func (m Model) keyColumn(g Group) column {
c := column{title: g.Title}
for _, b := range g.Keys {
if b.Enabled() {
c.rows = append(c.rows, [2]string{m.help.Styles.FullKey.Render(b.Help().Key), m.help.Styles.FullDesc.Render(b.Help().Desc)})
}
}
return c
}
// columns lays cols out side by side, each under its title, and last at the
// end. Like the global keys in the short bar, last is always kept: the
// columns before it that don't fit into w are left out from the right,
// marked with an ellipsis.
func (m Model) columns(cols []column, last column, w int) string {
tail := m.block(last)
avail := w - lipgloss.Width(tail) - len(columnGap)
var out []string
width := 0
for i, c := range cols {
b := m.block(c)
bw := lipgloss.Width(b) + len(columnGap)
if i == 0 {
bw -= len(columnGap)
}
if width+bw > avail {
for len(out) > 0 && width+2 > avail { // room for the ellipsis
width -= lipgloss.Width(out[len(out)-1]) + len(columnGap)
out = out[:len(out)-1]
}
if width+2 <= avail {
out = append(out, " "+m.help.Styles.Ellipsis.Render("…"))
}
break
}
out = append(out, b)
width += bw
}
var parts []string
for i, b := range out {
if i > 0 && !strings.HasPrefix(b, " ") {
parts = append(parts, columnGap)
}
parts = append(parts, b)
}
if len(parts) > 0 {
parts = append(parts, columnGap)
}
parts = append(parts, tail)
return lipgloss.NewStyle().MaxWidth(w).Render(lipgloss.JoinHorizontal(lipgloss.Top, parts...))
}
// block renders a column: its title, then its rows with the descriptions
// aligned.
func (m Model) block(c column) string {
left := 0
for _, r := range c.rows {
left = max(left, lipgloss.Width(r[0]))
}
lines := []string{theme.Fg(m.theme.Muted).Bold(true).Render(c.title)}
for _, r := range c.rows {
lines = append(lines, r[0]+strings.Repeat(" ", left-lipgloss.Width(r[0])+1)+r[1])
}
return strings.Join(lines, "\n")
}
// 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.
// left as fit; the submodule keys are dropped first, then the tools.
func (m Model) shortBar(w int) string {
h := m.help
h.Width = 0
@@ -97,9 +218,9 @@ func (m Model) shortBar(w int) string {
}
right += " " + m.theme.Highlight().Render(" "+strings.Join(parts, " ")+" ")
}
left := m.keys.Navigation()
left := append(slices.Clip(m.keys.Navigation()), m.keys.Tools()...)
if m.actions {
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
left = append(left, m.keys.Submodule()...)
}
for ; ; left = left[:len(left)-1] {
l := h.ShortHelpView(left)
+88 -3
View File
@@ -15,14 +15,25 @@ type testKeys struct{ highlight []key.Binding }
func bind(k, desc string) key.Binding { return key.NewBinding(key.WithKeys(k), key.WithHelp(k, desc)) }
func (testKeys) ShortHelp() []key.Binding { return nil }
func (testKeys) FullHelp() [][]key.Binding { return nil }
func (testKeys) Navigation() []key.Binding {
return []key.Binding{bind("j", "move"), bind("h", "fold")}
}
func (testKeys) Submodule() []key.Binding { return nil }
func (testKeys) Tools() []key.Binding {
return []key.Binding{bind("!", "problems only"), bind("/", "filter"), bind("f", "fetch")}
}
func (testKeys) Submodule() []key.Binding { return []key.Binding{bind("x", "restore to pin")} }
func (testKeys) Global() []key.Binding { return []key.Binding{bind("?", "help"), bind("Q", "quit")} }
func (k testKeys) Highlight() []key.Binding { return k.highlight }
func (testKeys) HelpGroups() []Group {
return []Group{
{Title: "Move", Keys: []key.Binding{bind("j", "down"), bind("k", "up")}},
{Title: "Selected submodule", Keys: []key.Binding{bind("x", "restore to pin")}},
{Title: "App", Keys: []key.Binding{bind("?", "what words mean"), bind("Q", "quit")}},
}
}
func (testKeys) LegendKeys() Group {
return Group{Title: "App", Keys: []key.Binding{bind("esc", "close")}}
}
func bar(w int, hl ...key.Binding) string {
m := New(theme.Default(), testKeys{hl})
@@ -52,3 +63,77 @@ func TestNavigationGivesWayToUpdate(t *testing.T) {
t.Errorf("the right group must be kept:\n%q", got)
}
}
func TestToolsFollowNavigation(t *testing.T) {
got := bar(80)
if !strings.Contains(got, "h fold • ! problems only • / filter • f fetch") {
t.Errorf("tools must follow the navigation keys:\n%q", got)
}
}
func TestToolsGiveWayBeforeNavigation(t *testing.T) {
got := bar(40)
if strings.Contains(got, "f fetch") || !strings.Contains(got, "j move") {
t.Errorf("tools must go before navigation:\n%q", got)
}
}
func TestSubmoduleKeysGoFirst(t *testing.T) {
m := New(theme.Default(), testKeys{})
m.SetActions(true)
m.SetWidth(80)
got := ansi.Strip(m.View())
if strings.Contains(got, "restore to pin") || !strings.Contains(got, "f fetch") {
t.Errorf("submodule keys must go before the tools:\n%q", got)
}
m.SetWidth(120)
if got := ansi.Strip(m.View()); !strings.Contains(got, "f fetch • x restore to pin") {
t.Errorf("submodule keys follow the tools:\n%q", got)
}
}
func TestHelpPages(t *testing.T) {
m := New(theme.Default(), testKeys{})
m.SetWidth(120)
m.CycleHelp()
keys := ansi.Strip(m.View())
for _, want := range []string{"Move", "Selected submodule", "App", "x restore to pin", "? what words mean"} {
if !strings.Contains(keys, want) {
t.Errorf("key page lacks %q:\n%s", want, keys)
}
}
m.CycleHelp()
if !m.Legend() {
t.Fatal("second page must be the legend")
}
legend := ansi.Strip(m.View())
for _, want := range []string{"Status", "off pin", "Symbols", "Commits", "esc close"} {
if !strings.Contains(legend, want) {
t.Errorf("legend lacks %q:\n%s", want, legend)
}
}
for _, l := range strings.Split(m.View(), "\n") {
if w := lipgloss.Width(l); w > 120 {
t.Errorf("legend line is %d wide: %q", w, ansi.Strip(l))
}
}
m.CycleHelp()
if got := ansi.Strip(m.View()); !strings.Contains(got, "? help") {
t.Errorf("third ? must close the help:\n%q", got)
}
}
func TestHelpLeavesOutColumnsThatDontFit(t *testing.T) {
m := New(theme.Default(), testKeys{})
m.SetWidth(30)
m.CycleHelp()
got := ansi.Strip(m.View())
if !strings.Contains(got, "App") || strings.Contains(got, "Selected") || !strings.Contains(got, "…") {
t.Errorf("want App kept, the columns before it left out with an ellipsis:\n%s", got)
}
for _, l := range strings.Split(m.View(), "\n") {
if w := lipgloss.Width(l); w > 30 {
t.Errorf("line is %d wide: %q", w, ansi.Strip(l))
}
}
}
+2 -5
View File
@@ -72,16 +72,13 @@ func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, widt
if errored > 0 {
parts = append(parts, t.LevelStyle(submodule.LevelDanger, bar).Render(fmt.Sprintf("%d error", errored)))
}
for _, k := range []submodule.IssueKind{
submodule.IssueUnpushed, submodule.IssueConflict, submodule.IssueMissing,
submodule.IssueOffPin, submodule.IssueChanged, submodule.IssueOffTarget,
} {
for _, k := range submodule.Kinds {
if n := perKind[k]; n > 0 {
parts = append(parts, t.LevelStyle(level[k], bar).Render(fmt.Sprintf("%d %s", n, theme.IssueWord(k))))
}
}
if staged > 0 {
parts = append(parts, seg(t.Info, fmt.Sprintf("%d pin staged", staged)))
parts = append(parts, seg(t.Info, fmt.Sprintf("%d %s", staged, theme.Staged)))
}
if loading > 0 {
parts = append(parts, seg(t.Muted, fmt.Sprintf("%d loading", loading)))
+41 -15
View File
@@ -2,6 +2,8 @@ package tui
import (
"github.com/charmbracelet/bubbles/key"
"submarine/internal/tui/footer"
)
type keyMap struct {
@@ -9,7 +11,8 @@ type keyMap struct {
CollapseAll, ExpandAll key.Binding
Home, End, PageUp, PageDown key.Binding
Fetch, Reload, Filter, Issues key.Binding
Copy, Restore, Switch key.Binding
Copy, Restore, Switch, Update key.Binding
HardRestore key.Binding
Help, Quit, QuitUpdate key.Binding
}
@@ -30,8 +33,10 @@ func defaultKeys() keyMap {
Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")),
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore to pin")),
HardRestore: key.NewBinding(key.WithKeys("X"), key.WithHelp("X", "restore committed pins")),
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "update pin")),
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
@@ -49,14 +54,21 @@ func (k *keyMap) SetUpdate(v string) {
// 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"))
fold := key.NewBinding(key.WithKeys("left"), key.WithHelp("←→/hl", "collapse/expand"))
all := key.NewBinding(key.WithKeys("H"), key.WithHelp("⇧←→/HL", "all"))
return []key.Binding{move, fold, all, k.Toggle}
}
// Tools returns the bindings that act on the whole list: problems only,
// filter and fetch, most useful first, since the footer drops them from the
// end. It shows them after the navigation keys.
func (k keyMap) Tools() []key.Binding { return []key.Binding{k.Issues, k.Filter, k.Fetch} }
// 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, k.Switch, k.Restore} }
func (k keyMap) Submodule() []key.Binding {
return []key.Binding{k.Copy, k.Update, k.Switch, k.Restore, k.HardRestore}
}
// Global returns the bindings that don't depend on the selection.
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
@@ -78,17 +90,31 @@ func (k keyMap) Highlight() []key.Binding {
return []key.Binding{k.QuitUpdate}
}
func (k keyMap) ShortHelp() []key.Binding {
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
// forSelection returns the keys as they read for the selection: on the
// superproject, u fast-forwards its branch (D29).
func (k keyMap) forSelection(root bool) keyMap {
if root {
k.Update.SetHelp("u", "pull (fast-forward)")
}
return k
}
func (k keyMap) FullHelp() [][]key.Binding {
return [][]key.Binding{
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
{k.CollapseAll, k.ExpandAll},
{k.Home, k.End, k.PageUp, k.PageDown},
{k.Copy, k.Switch, k.Restore},
{k.Fetch, k.Reload, k.Filter, k.Issues},
append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
// HelpGroups returns the columns of the full help, each with a title.
func (k keyMap) HelpGroups() []footer.Group {
help := key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "what words mean"))
esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"))
return []footer.Group{
{Title: "Move", Keys: []key.Binding{k.Up, k.Down, k.Left, k.Right, k.Toggle, k.CollapseAll, k.ExpandAll}},
{Title: "Jump", Keys: []key.Binding{k.Home, k.End, k.PageUp, k.PageDown}},
{Title: "Selected submodule", Keys: k.Submodule()},
{Title: "All submodules", Keys: []key.Binding{k.Fetch, k.Reload, k.Filter, k.Issues}},
{Title: "App", Keys: append([]key.Binding{help, esc, k.Quit}, k.fullUpdate()...)},
}
}
// LegendKeys returns the column next to the legend, the help's last page;
// ? closes it too, but esc is the key to tell.
func (k keyMap) LegendKeys() footer.Group {
esc := key.NewBinding(key.WithKeys("esc"), key.WithHelp("esc", "close"))
return footer.Group{Title: "App", Keys: append([]key.Binding{esc, k.Quit}, k.fullUpdate()...)}
}
+16
View File
@@ -0,0 +1,16 @@
package tui
import "testing"
func TestUpdateReadsPullOnTheSuperproject(t *testing.T) {
k := defaultKeys()
if got := k.forSelection(true).Update.Help().Desc; got != "pull (fast-forward)" {
t.Errorf("superproject: u reads %q", got)
}
if got := k.forSelection(false).Update.Help().Desc; got != "update pin" {
t.Errorf("submodule: u reads %q", got)
}
if got := k.Update.Help().Desc; got != "update pin" {
t.Errorf("forSelection changed the key map: u reads %q", got)
}
}
+101
View File
@@ -0,0 +1,101 @@
package tui
import (
"context"
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
)
// startPinUpdate moves the pin of the selected submodule to the tip of its
// ref, if the pin is only behind it: a switch to that branch, with the tip
// the row shows. Nothing is fetched. For the superproject the pin is HEAD
// and the ref its upstream: a fast-forward of its branch.
func (m Model) startPinUpdate() (tea.Model, tea.Cmd) {
s := m.tree.Selected()
if s == nil {
return m, nil
}
_, ok := m.provider.(submodule.Switcher)
var flash string
switch {
case !ok:
flash = "this data source can't update pins"
case m.restore != nil:
flash = "wait for the restore of " + m.restore.name + " to finish"
default:
flash = pinUpdateBlocker(s)
}
if flash != "" {
cmd := m.footer.Flash(flash)
m.layout()
return m, cmd
}
st := s.Status
m.restore = &restoreState{path: s.Path, name: restoreName(s), pinUpdate: true, phase: restoreChecking}
m.layout()
ctx, path, ref, sha := m.ctx, s.Path, st.Pin.Ref, st.Tip.SHA
sw := m.provider.(submodule.Switcher)
check := func() tea.Msg {
b, err := pinUpdateBranch(ctx, sw, path, ref, sha)
if err != nil {
return lossMsg{path: path, err: err}
}
loss, err := sw.SwitchLoss(ctx, path, *b)
return lossMsg{path, loss, err, b}
}
return m, tea.Batch(check, m.tick())
}
// pinUpdateBlocker says why the pin of s can't be updated, "" if it can.
func pinUpdateBlocker(s *submodule.Submodule) string {
st, name := s.Status, restoreName(s)
pin := "the pin of " + name
if s.IsRoot() {
pin = name // its pin is HEAD
}
switch {
case st == nil:
return name + " is still loading"
case st.Presence != submodule.PresenceOK:
return name + " is not initialized; restore it first"
case st.Pin.Ref == "" || st.Tip == nil || st.Pin.Source == submodule.RefFromTag || st.Pin.Source == submodule.RefFromSHA:
if s.IsRoot() {
return name + " has no upstream branch"
}
return pin + " is not on a branch"
case st.Pin.Ahead > 0 && st.Pin.Behind > 0:
return pin + " has diverged from " + st.Pin.Ref + "; b switches the branch"
case st.Pin.Ahead > 0:
return pin + " is ahead of " + st.Pin.Ref
case st.Pin.Behind == 0:
return name + " is up to date with " + st.Pin.Ref
}
return ""
}
// pinUpdateBranch finds the branch ref among the branches of path. Its tip
// must still be sha, the tip the row showed (abbreviated, see Commit).
func pinUpdateBranch(ctx context.Context, sw submodule.Switcher, path, ref, sha string) (*submodule.Branch, error) {
bs, err := sw.Branches(ctx, path)
if err != nil {
return nil, err
}
for _, b := range bs {
if b.Name != ref {
continue
}
if !b.Remote {
return nil, fmt.Errorf("%s is a local branch; b switches to it", ref)
}
if !strings.HasPrefix(b.SHA, sha) {
return nil, fmt.Errorf("%s moved; check the new state", ref)
}
return &b, nil
}
return nil, fmt.Errorf("%s not found", ref)
}
+143
View File
@@ -0,0 +1,143 @@
package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule"
)
// newPinUpdateModel is the switch model with the first submodule selected
// and given st as its status.
func newPinUpdateModel(t *testing.T, loss submodule.Loss, st *submodule.Status) (tea.Model, *fakeSwitcher, string) {
t.Helper()
m, f := newSwitchModel(t, loss)
repo, err := f.Tree(context.Background())
if err != nil {
t.Fatal(err)
}
path := repo.Submodules[0].Path
m, _ = m.Update(statusLoadedMsg{path: path, status: st})
mm := m.(Model)
mm.tree.Select(path)
return mm, f, path
}
// behind is a pin two commits behind origin/feat, whose tip is bbbbbbb.
func behind() *submodule.Status {
return &submodule.Status{
Pin: submodule.PinInfo{Ref: "origin/feat", Behind: 2, Pushed: true, Source: submodule.RefFromGitmodules},
Tip: &submodule.Commit{SHA: "bbbb"}, // abbreviated, like the git provider's
}
}
func TestPinUpdateSwitchesToTheTipOfTheRef(t *testing.T) {
m, f, path := newPinUpdateModel(t, submodule.Loss{}, behind())
m, cmd := press(m, "u")
m = settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != path+" refs/remotes/origin/feat" {
t.Fatalf("switched %q", f.switched)
}
if v := m.View(); !strings.Contains(v, "updated "+path+" to feat") {
t.Errorf("no flash:\n%s", v)
}
}
func TestPinUpdateAsksWhenSomethingIsLost(t *testing.T) {
m, f, path := newPinUpdateModel(t, submodule.Loss{Files: 1, Modules: 1}, behind())
m, cmd := press(m, "u")
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, _ = m.Update(l)
}
}
if v := m.View(); !strings.Contains(v, "Update "+path+" to origin/feat?") || !strings.Contains(v, "y update") {
t.Errorf("question not shown:\n%s", v)
}
m, cmd = press(m, "y")
feed(m, cmd)
if len(f.switched) != 1 {
t.Fatalf("switched %q", f.switched)
}
}
func TestPinUpdateAbortsWhenTheRefMoved(t *testing.T) {
st := behind()
st.Tip.SHA = "ccccccc" // the row showed another tip than the branch has now
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
m, cmd := press(m, "u")
m = settle(m, cmd)
if len(f.switched) != 0 {
t.Fatalf("switched %q", f.switched)
}
if v := m.View(); !strings.Contains(v, "origin/feat moved") {
t.Errorf("no notice:\n%s", v)
}
}
func TestPinUpdateOnlyWhenThePinIsBehind(t *testing.T) {
for _, tc := range []struct {
name string
edit func(*submodule.Status)
want string
}{
{"up to date", func(s *submodule.Status) { s.Pin.Behind = 0 }, "is up to date with origin/feat"},
{"ahead", func(s *submodule.Status) { s.Pin.Behind, s.Pin.Ahead = 0, 1 }, "is ahead of origin/feat"},
{"diverged", func(s *submodule.Status) { s.Pin.Ahead = 1 }, "has diverged from origin/feat"},
{"tag", func(s *submodule.Status) { s.Pin.Source = submodule.RefFromTag }, "is not on a branch"},
{"missing", func(s *submodule.Status) { s.Presence = submodule.NotInitialized }, "is not initialized"},
} {
t.Run(tc.name, func(t *testing.T) {
st := behind()
tc.edit(st)
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
m, _ = press(m, "u") // the command is only the flash's timer
if len(f.switched) != 0 || m.(Model).restore != nil {
t.Fatalf("updated anyway: %q", f.switched)
}
if v := m.View(); !strings.Contains(v, tc.want) {
t.Errorf("flash lacks %q:\n%s", tc.want, v)
}
})
}
}
func TestPinUpdateOfTheSuperprojectFastForwardsItsBranch(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{}) // the superproject is selected
st := behind()
st.Root = true
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
m, cmd := press(m, "u")
m = settle(m, cmd)
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
t.Fatalf("switched %q", f.switched)
}
if v := m.View(); !strings.Contains(v, "updated the superproject to feat") {
t.Errorf("no flash:\n%s", v)
}
}
func TestPinUpdateOfTheSuperprojectWithoutUpstreamSaysSo(t *testing.T) {
m, f := newSwitchModel(t, submodule.Loss{})
st := behind()
st.Root, st.Pin.Source, st.Tip = true, submodule.RefFromSHA, nil
m, _ = m.Update(statusLoadedMsg{path: "", status: st})
m, _ = press(m, "u")
if len(f.switched) != 0 || !strings.Contains(m.View(), "the superproject has no upstream branch") {
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
}
}
func TestPinUpdateRefusesALocalRef(t *testing.T) {
st := behind()
st.Pin.Ref, st.Tip.SHA = "main", "aaaa" // upstream is a local branch
m, f, _ := newPinUpdateModel(t, submodule.Loss{}, st)
m, cmd := press(m, "u")
m = settle(m, cmd)
if len(f.switched) != 0 || !strings.Contains(m.View(), "main is a local branch") {
t.Errorf("switched %q, view:\n%s", f.switched, m.View())
}
}
+56 -19
View File
@@ -28,13 +28,15 @@ type restoreState struct {
to submodule.RestoreTarget
loss submodule.Loss
branch *submodule.Branch // set for a branch switch, which reuses this flow
pinUpdate bool // a switch to the pin's ref (u); branch is set by the check
}
// lossMsg is the result of the check before a restore.
type lossMsg struct {
path string
loss submodule.Loss
err error
path string
loss submodule.Loss
err error
branch *submodule.Branch // the branch a pin update switches to
}
type restoreDoneMsg struct {
@@ -45,8 +47,9 @@ type restoreDoneMsg struct {
// startRestore checks what restoring the selection would discard. If nothing
// would be lost it restores at once, else it asks first. A submodule with a
// staged pin first asks which pin to restore to.
func (m Model) startRestore() (tea.Model, tea.Cmd) {
// staged pin first asks which pin to restore to; a hard restore doesn't: it
// goes to the committed pins, at every depth.
func (m Model) startRestore(hard bool) (tea.Model, tea.Cmd) {
s := m.tree.Selected()
_, ok := m.provider.(submodule.Restorer)
var flash string
@@ -65,6 +68,10 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
}
m.restore = &restoreState{path: s.Path, name: restoreName(s)}
if hard {
m.restore.to = submodule.RestoreHard
return m.checkRestore()
}
if !s.IsRoot() && s.Status != nil && s.Status.PinStaged {
m.restore.phase = restoreChoosing
m.layout()
@@ -86,7 +93,7 @@ func (m Model) checkRestore() (tea.Model, tea.Cmd) {
} else {
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
}
return lossMsg{path, loss, err}
return lossMsg{path: path, loss: loss, err: err}
}
return m, tea.Batch(check, m.tick())
}
@@ -115,11 +122,18 @@ func (m Model) lossChecked(msg lossMsg) (tea.Model, tea.Cmd) {
return m, nil
}
if msg.err != nil {
verb := "check"
if m.restore.pinUpdate {
verb = "update"
}
m.restore = nil
m.notice = "can't check " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
m.notice = "can't " + verb + " " + restoreName(m.byPath[msg.path]) + ": " + msg.err.Error()
m.layout()
return m, nil
}
if msg.branch != nil {
m.restore.branch = msg.branch
}
m.restore.loss = msg.loss
if msg.loss.Empty() {
return m.runRestore()
@@ -165,15 +179,15 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
var cmds []tea.Cmd
switch {
case r != nil && r.branch != nil && msg.err != nil:
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
m.notice = r.noun() + " of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
case r != nil && r.branch != nil:
to := r.branch.Name
if msg.branch != "" {
to = msg.branch
} else if r.branch.Remote {
to += " (detached: local " + r.branch.Local + " is elsewhere)"
to += " (detached: local " + r.branch.Local + " can't be fast-forwarded)"
}
cmds = append(cmds, m.footer.Flash("switched "+r.name+" to "+to))
cmds = append(cmds, m.footer.Flash(r.verbEd()+" "+r.name+" to "+to))
default:
name := "the superproject"
if r != nil {
@@ -190,34 +204,57 @@ func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
return m, tea.Batch(cmds...)
}
// toText is " to origin/feat" for a switch, " to the committed pin" if that was chosen; the staged pin is the
// usual target and needs no words.
// toText is " to origin/feat" for a switch, " to the committed pin" if that
// was chosen, " to the committed pins" for a hard restore; the staged pin is
// the usual target and needs no words.
func (r *restoreState) toText() string {
if r.branch != nil {
switch {
case r.branch != nil:
return " to " + r.branch.Name
}
if r.to == submodule.RestoreCommitted {
case r.to == submodule.RestoreHard:
return " to the committed pins"
case r.to == submodule.RestoreCommitted:
return " to the committed pin"
}
return ""
}
// verb and verbIng word the operation: a switch is shown like a restore, but
// names its branch.
// verb, verbIng, verbEd and noun word the operation: a switch and a pin
// update are shown like a restore, but name their branch.
func (r *restoreState) verb() string {
if r.branch != nil {
switch {
case r.pinUpdate:
return "Update"
case r.branch != nil:
return "Switch"
}
return "Restore"
}
func (r *restoreState) verbIng() string {
if r.branch != nil {
switch {
case r.pinUpdate:
return "updating"
case r.branch != nil:
return "switching"
}
return "restoring"
}
func (r *restoreState) verbEd() string {
if r.pinUpdate {
return "updated"
}
return "switched"
}
func (r *restoreState) noun() string {
if r.pinUpdate {
return "update"
}
return "switch"
}
func restoreName(s *submodule.Submodule) string {
if s == nil || s.IsRoot() {
return "the superproject"
+52
View File
@@ -3,6 +3,7 @@ package tui
import (
"context"
"os"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
@@ -187,3 +188,54 @@ func TestRestoreOfAStagedPinAsksWhichPin(t *testing.T) {
t.Fatalf("restored %q to %v, want %s to the committed pin", f.restored, f.to, staged.Path)
}
}
func TestHardRestoreAsksToDropStagedPins(t *testing.T) {
m, f := newRestoreModel(t, submodule.Loss{StagedPins: 2, Modules: 2})
mm := m.(Model)
var staged *submodule.Submodule
submodule.Walk(mm.repo.Submodules, func(s *submodule.Submodule) {
if staged == nil && s.Children == nil {
staged = s
}
})
staged.Status = &submodule.Status{PinStaged: true, Pin: submodule.PinInfo{Pushed: true}}
mm.tree.Select(staged.Path)
m = mm
// not which pin, but y/n: staged pins alone are a reason to ask
m, cmd := press(m, "X")
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, _ = m.Update(l)
}
}
r := m.(Model).restore
if r == nil || r.phase != restoreAsking {
t.Fatalf("want a question, got %+v", r)
}
if got := lossText(r.loss); got != "2 staged pins" {
t.Errorf("question lists %q, want the staged pins", got)
}
m, cmd = press(m, "y")
feed(m, cmd)
if len(f.to) != 1 || f.to[0] != submodule.RestoreHard || f.restored[0] != staged.Path {
t.Fatalf("restored %q to %v, want %s hard", f.restored, f.to, staged.Path)
}
}
func TestHardRestoreAsksWhenWorkIsLost(t *testing.T) {
m, _ := newRestoreModel(t, submodule.Loss{Files: 1, StagedPins: 1, Modules: 1})
m, cmd := press(m, "X")
for _, msg := range run(cmd) {
if l, ok := msg.(lossMsg); ok {
m, _ = m.Update(l)
}
}
r := m.(Model).restore
if r == nil || r.phase != restoreAsking {
t.Fatalf("want a question, got %+v", r)
}
if got := lossText(r.loss); !strings.Contains(got, "1 staged pin") {
t.Errorf("question %q doesn't list the staged pin", got)
}
}
+62
View File
@@ -0,0 +1,62 @@
package theme
import "submarine/internal/submodule"
// LegendRow is one word or symbol of the UI, rendered as it appears, and
// what it means.
type LegendRow struct{ Word, Meaning string }
// LegendGroup is a titled column of the legend.
type LegendGroup struct {
Title string
Rows []LegendRow
}
// issueMeaning explains an issue word in a few plain words.
func issueMeaning(k submodule.IssueKind) string {
switch k {
case submodule.IssueUnpushed:
return "pin is on no remote"
case submodule.IssueConflict:
return "merge conflict"
case submodule.IssueMissing:
return "not checked out"
case submodule.IssueOffPin:
return "checkout is not the pin"
case submodule.IssueChanged:
return "uncommitted files"
default:
return "pin not on target branch"
}
}
// Legend explains the words and symbols of the tree and the detail pane, in
// their colors: what the full help's second page shows.
func (t Theme) Legend() []LegendGroup {
g := t.Glyphs
muted := Fg(t.Muted)
status := LegendGroup{Title: "Status"}
for _, k := range submodule.Kinds {
status.Rows = append(status.Rows, LegendRow{t.LevelStyle(k.Level(), Fg(t.Text)).Render(IssueWord(k)), issueMeaning(k)})
}
status.Rows = append(status.Rows,
LegendRow{Fg(t.Info).Render(Staged), "new pin, not committed"},
LegendRow{muted.Render(InSync), "nothing to do"},
)
symbols := LegendGroup{Title: "Symbols", Rows: []LegendRow{
{Fg(t.Info).Render(g.Staged), "pin staged in parent"},
{muted.Render(g.Ahead + "n " + g.Behind + "n"), "ahead / behind the ref"},
{muted.Render("+n"), "n more issues"},
{Fg(t.Warn).Render(g.Problems + "k"), "issues below, collapsed"},
{muted.Render("[n]"), "children, collapsed"},
}}
commits := LegendGroup{Title: "Commits", Rows: []LegendRow{
{muted.Render(RoleWord(submodule.RolePin, "")), "what the parent records"},
{Fg(t.Text).Bold(true).Render(RoleWord(submodule.RoleYou, "")), "the checkout"},
{muted.Render(RoleWord(submodule.RoleTip, "")), "newest on the ref"},
{muted.Render("main"), "tip of target branch"},
{muted.Render(RoleWord(submodule.RoleBase, "")), "where lines split"},
{muted.Render(RoleWord(submodule.RoleHead, "")), "superproject checkout"},
}}
return []LegendGroup{status, symbols, commits}
}
+42
View File
@@ -66,3 +66,45 @@ func TestQuitAndUpdate(t *testing.T) {
t.Errorf("ctrl+c must quit without updating, got update to %q", m2.(Model).UpdateTo())
}
}
func TestHintUntilTheFirstKey(t *testing.T) {
m := newUpdateModel("")
if !m.(Model).extraLine() || !strings.Contains(m.View(), "new here?") {
t.Fatal("no hint on start")
}
m, _ = press(m, "j")
if m.(Model).extraLine() || strings.Contains(m.View(), "new here?") {
t.Error("the hint must go away with the first key")
}
}
func TestEscClosesTheHelp(t *testing.T) {
for _, presses := range []int{1, 2} { // the keys page, the legend
m := newUpdateModel("")
for range presses {
m, _ = press(m, "?")
}
if !m.(Model).footer.HelpOpen() {
t.Fatalf("%d × ?: no help", presses)
}
m, _ = pressKey(m, tea.KeyEsc)
if m.(Model).footer.HelpOpen() {
t.Errorf("%d × ?: esc must close the help", presses)
}
}
}
func TestEscClosesTheHelpBeforeTheFilter(t *testing.T) {
m := newUpdateModel("")
m, _ = press(m, "/")
m, _ = pressKey(m, tea.KeyEnter) // keys back to the tree, the filter stays
m, _ = press(m, "?")
m, _ = pressKey(m, tea.KeyEsc)
if mm := m.(Model); mm.footer.HelpOpen() || !mm.filtering {
t.Errorf("first esc: help open %v, filtering %v; want the help closed, the filter kept", mm.footer.HelpOpen(), mm.filtering)
}
m, _ = pressKey(m, tea.KeyEsc)
if m.(Model).filtering {
t.Error("second esc must close the filter")
}
}
+240
View File
@@ -0,0 +1,240 @@
# Usability plan (draft)
**Status:** items 1–8 and 11 are done ([D31](../docs/decisions.md),
[D32](../docs/decisions.md)); the rest is a proposal. Items that change
behavior need a decision in [decisions.md](../docs/decisions.md) and
updates to [ux-guide.md](../docs/ux-guide.md) /
[actions.md](../docs/actions.md) when they are taken up.
Done differently than proposed below: the labels are `x restore to pin` and
`X restore committed pins`; the tools read `! problems only • / filter • f
fetch` (most useful first, dropped from the end); `space toggle` stays, as
it now follows `←→/hl collapse/expand`.
## Goal
The target user is a developer who doesn't know git submodules well. They
should understand the screen and act safely **without reading a manual**.
At any moment the screen should answer three questions:
1. **What is wrong?** What does this state mean, in plain words?
2. **What can I do about it?** Which key fixes it?
3. **What will this key do?** What happens to my work, and can I undo it?
Today the tool answers (1) partly (the issue lines in the detail pane) and
(3) well *after* a key is pressed (loss question, flash). It hardly answers
(2), and it answers (3) only by trying. Most items below move the answers
from "after pressing" to "before pressing".
## Findings this is based on
From a walkthrough on the `make-fixture.sh` superproject (2026-10-24):
- `x restore` and `X hard restore` don't say the target ("to the pin"), the
scope (the submodule and everything below it) or how they differ. "Hard"
suggests "discards my changes", but both do that. The real difference,
staged pins, can't be guessed.
- `X` drops a staged pin without asking (`restored libs/staged to the
committed pins`), one Shift away from `x`.
- `f` fetch, `/` filter and `!` problems only appear only in `?`. The detail
pane says `fetched never` but gives no hint how to fetch. `!` is the most
useful view for many submodules, and nothing points to it.
- The words in the tree (`off pin`, `off target`, `staged`, `●`, `·1`, `↓2`)
and on the spine (`pin`, `tip`, `you`, `base`) are explained nowhere in
the UI.
- `u update pin` on the superproject actually fast-forwards the current
branch; the label stays the same.
- The palette is static: `u` is offered when it can't do anything, and the
user finds out from a flash.
- Mismatched words: the footer says "fold", the help "collapse/expand";
`space toggle` doesn't say what it toggles.
- Good, and worth keeping: questions only when something would be lost, the
loss listed exactly, flashes that say why and suggest another key.
## Overview
Effort: **S** = hours, mostly labels and docs; **M** = a day or two, new
logic in existing components; **L** = several days, new UI or new provider
calls.
Impact: how much it helps the target user's main flows (*understand state*,
*fix a submodule*, *find problems*, *stay safe*).
| # | Item | Effort | Impact | Flow |
|---|---|---|---|---|
| 1 ✓ | Clear names for restore and hard restore | S | High | fix, safe |
| 2 ✓ | Ask before `X` drops staged pins | S | High | safe |
| 3 ✓ | Show `/`, `!`, `f` in the footer | S | High | find |
| 4 ✓ | Status legend in `?` | S–M | High | understand |
| 5 ✓ | Groups with titles in the full help | S | Medium | understand |
| 6 ✓ | Plain-word fixes for wording | S | Low | understand |
| 7 ✓ | `u` label for the superproject | S | Medium | fix |
| 8 ✓ | Fetch hint at `fetched never` / old fetches | S | Medium | understand |
| 9 | Next step for each issue in the detail pane | M | **Highest** | understand, fix |
| 10 | Palette that fits the selection | M | High | fix |
| 11 ✓ | First-start hint | S | Medium | understand |
| 12 | Undo hint after a restore, switch or update | M | Medium | safe |
| 13 | Action menu on `enter` | M–L | High | fix, understand |
| 14 | "What would happen" preview | L | Medium | safe |
Quick view by quadrant:
| | **Low effort** | **Medium effort** | **High effort** |
|---|---|---|---|
| **High impact** | 1, 2, 3, 4 | 9, 10, 13 | |
| **Medium impact** | 5, 7, 8, 11 | 12 | 14 |
| **Low impact** | 6 | | |
## Items
### Low effort
**1. Clear names for restore and hard restore.** *S · High*
The labels should give target and difference without the docs.
Proposal: `x restore to pin`, `X restore, unstage pins`. In the full help,
longer: `x restore to pin (with everything below)`, `X restore to committed
pins, drop staged ones`. The question lines already use "to the committed
pins"; keep the same words everywhere.
Alternative: rename the action to "reset". Rejected for now: `git reset`
moves branches, which restore never does (D23).
Docs: ux-guide key reference, actions.md "Keys and palette", README.
**2. Ask before `X` drops staged pins.** *S · High*
Amends D30 ("alone they don't make it ask"). A staged pin is a decision
the user made, and `X` sits next to `x`. If `X` would drop at least one
staged pin, ask: `Restore src/app to the committed pins? Unstages 2 pins.
y restore · n cancel`. The loss is already counted, so only the trigger
changes. D30's argument (the commit stays in the submodule) holds for an
expert, not for someone who doesn't know where to find it again.
**3. Show `/`, `!`, `f` in the footer.** *S · High*
Proposal: `/ filter • ! problems • f fetch` after the navigation keys,
before the global keys. The existing rule drops keys from the left group's
end when the footer is too narrow, so narrow terminals lose them first and
nothing else changes. While problems only is on, the footer could read
`! all` (the action the key does now). `r reload` stays in `?`.
**4. Status legend in `?`.** *S–M · High*
The full help gets a second block, "What the words mean", with one line per
status word and symbol, using the same colors as the tree:
`off pin the checkout is not the commit the parent records`, `staged`,
`●`, `↑n ↓n`, `·k`, and the spine roles (`pin`, `tip`, `you`, `base`,
target). Wording comes from state-model.md; the explanations the detail
pane already uses are a good source. Layout: below the key columns if there
is room, otherwise `?` pages between keys and legend (`? more`).
**5. Groups with titles in the full help.** *S · Medium*
The help columns are already grouped, but without titles. Add short titles
on top: `Move`, `Selected submodule`, `All repositories`, `App`. Then it is
clear that `x` acts on the selection and `f` on everything.
**6. Plain-word fixes.** *S · Low*
- Footer `fold` → `collapse/expand`; `⇧←→/HL fold all` → `H/L all`.
- `space toggle` → `space open/close`.
- `c copy path` → say which path (`copy path` is fine if it is the
relative path; check).
- Header `pin staged` vs tree `staged`: one word.
**7. `u` label for the superproject.** *S · Medium*
With the header selected the palette says `u pull (fast-forward)`, since
that is what it does (D29). Labels that change with the selection need
item 10's mechanism, but this one is simple on its own: one check for the
header.
**8. Fetch hint.** *S · Medium*
`fetched never` (and an age above a threshold, e.g. a day) gets a muted
`· f fetches`. Users who don't know that the arrows (`↓2`) are only as fresh
as the last fetch otherwise trust old numbers.
**11. First-start hint.** *S · Medium*
On start, the footer's flash line shows `? explains keys and states` until
the first key. No config file, no state: it is shown every start, and it is
just a muted line. Makes item 4 discoverable.
### Medium effort
**9. Next step for each issue in the detail pane.** *M · Highest*
Each issue line ends with what to do about it, as keys:
```
off pin checkout is not the pin x back to pin · b record a branch
changed 3 files changed commit outside, or x discard
missing not initialized x restore
off target pin is not on origin/main b switch branch
```
A pin that is only behind (`↓2`, no issue) gets the same kind of hint on
its `Ref` line: `u update pin`.
This is the single biggest step for the target user: they no longer need to
know what to do, only to read. Layering: which action resolves which issue
is meaning, not looks, so it belongs in `internal/submodule` (e.g. a
`Remedies()` on the issue, returning action kinds) and the words in
`theme`. It must agree with what the actions really allow (e.g. `u` only
when behind and not ahead, D29), which item 10 needs anyway.
**10. Palette that fits the selection.** *M · High*
Amends actions.md ("the palette is static"). The palette keeps the same keys
in the same places (muscle memory), but:
- keys that can't do anything for the selection are drawn muted (`u` when
up to date, `b` on a missing submodule); pressing them still flashes why;
- the key that item 9 recommends for the most severe issue is highlighted.
The checks for `u` already exist as flash reasons; they are cheap and need
only the status, so this is a matter of calling them for the palette too.
Restore stays always active (it is idempotent).
**12. Undo hint after a restore, switch or update.** *M · Medium*
The success flash names the old commit when a repository moved:
`restored src/app (was 1a2b3c4)`. A flash only lasts a moment, so the detail
pane could keep `previous 1a2b3c4` until the next reload. For a user who
doesn't know `git reflog`, this is the difference between "lost" and
"recoverable". No new provider call if the SHA is taken before the action.
**13. Action menu on `enter`.** *M–L · High*
`enter` on a row opens a small popup (reusing the branch popup's frame)
listing the actions for this submodule, each with one line of explanation
and its key; the recommended one first and selected. `enter` runs it, `esc`
closes. Users who know the keys never need it; users who don't need no
memory at all. `enter` is unused in the tree today.
### High effort
**14. "What would happen" preview.** *L · Medium*
In the action menu (13), the selected entry shows the effect before
running it: `moves the pin e510d9b → 906f119 (2 commits)`, `discards 3
changed files`. The loss check exists, but it is async git work, so it runs
when the entry is selected, with a spinner. Valuable, but the loss question
already covers the dangerous cases, so this comes last.
## Order
1. **Labels and safety** (1, 2, 3, 5, 6, 7): only labels, docs and one
question trigger. Fixes the worst risk (`X`) and the hidden keys at once.
2. **Explain the screen** (4, 8, 11): the legend and the hints that point
to it.
3. **Guide to the fix** (9, 10): issue → key, and a palette that agrees.
Needs a decision on where remedies live (layering).
4. **Menus and previews** (12, 13, 14).
## How to check
Give someone who hasn't used submarine the fixture (`make-fixture.sh`) and
these tasks, without the docs:
- Find every submodule that needs attention.
- Get `ui` up to date with its branch.
- Put `core` back where the superproject wants it.
- Find out whether the arrows are current.
- Explain what `staged` on `libs/staged` means.
Note keys tried, wrong actions and questions asked. Run it before phase 1
and after each phase.
## Open questions
- Item 1: are "restore to pin" / "restore, unstage pins" the right words, or
should `X` move under a different key that is not one Shift away?
- Item 2: ask for every dropped staged pin, or only for the selection's own?
- Item 9: are external actions (`commit outside submarine`) good hints, or
only keys?
- Item 13: does `enter` stay free for something else (e.g. opening a shell
in the submodule)?