Archived
Compare commits
4
Commits
v0.10.0
...
c9932932bb
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c9932932bb | ||
|
|
b205576759 | ||
|
|
72500353cd | ||
|
|
fbd233f58d |
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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
@@ -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
@@ -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
@@ -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 |
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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
@@ -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)
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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()...)}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}
|
||||
}
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)?
|
||||
Reference in New Issue
Block a user