Archived
Compare commits
4
Commits
v0.6.0
...
aeedfe96d4
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
aeedfe96d4 | ||
|
|
01a9babe64 | ||
|
|
648e1fd46c | ||
|
|
2b6b781826 |
@@ -0,0 +1,158 @@
|
|||||||
|
---
|
||||||
|
name: release
|
||||||
|
description: Prepare and publish a submarine release. Finds the latest release, picks the next semver from the changes since, runs a release check, creates and pushes the git tag, hands over to the developer for `make release`, then creates the Gitea release with MCP. Use only when the user asks to release.
|
||||||
|
disable-model-invocation: true
|
||||||
|
---
|
||||||
|
|
||||||
|
# Release submarine
|
||||||
|
|
||||||
|
A release is built on the developer's machine (`make release`, see
|
||||||
|
`docs/architecture.md#distribution`). `make release` publishes the binaries
|
||||||
|
of the **clean `vX.Y.Z` tag at HEAD** to the Gitea package registry. This
|
||||||
|
skill does everything around it. The developer runs `make release`
|
||||||
|
themselves, in the middle: you never run it and never read or print `.env`
|
||||||
|
or `GITEA_TOKEN`.
|
||||||
|
|
||||||
|
Pushing a tag and creating a release are outward-facing and hard to undo.
|
||||||
|
Do them only after the confirmation in step 5, and never force-push, move or
|
||||||
|
delete a tag without asking.
|
||||||
|
|
||||||
|
Gitea: owner `simonfranken`, repo `submarine`, `https://git.simon-franken.de`.
|
||||||
|
|
||||||
|
## 1. Preconditions
|
||||||
|
|
||||||
|
Stop and tell the user, if any of these fails:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git fetch origin --tags
|
||||||
|
git branch --show-current # main
|
||||||
|
git status --porcelain # empty: make release refuses a dirty tree
|
||||||
|
git rev-parse HEAD origin/main # equal: HEAD is pushed, nothing to release from a local-only commit
|
||||||
|
git tag --points-at HEAD # empty: HEAD is not released yet
|
||||||
|
grep -q '^GITEA_TOKEN=.\+' .env # exists: only test it, never print it
|
||||||
|
```
|
||||||
|
|
||||||
|
## 2. Latest release
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag --list 'v[0-9]*.[0-9]*.[0-9]*' --sort=-v:refname | head -1
|
||||||
|
```
|
||||||
|
|
||||||
|
Cross-check with the `mcp__gitea__get_latest_release` MCP tool (load it with
|
||||||
|
ToolSearch first). If the tag and the Gitea release differ (a tag without a
|
||||||
|
release is normal for the one in progress, a release without a tag is not),
|
||||||
|
tell the user and ask before going on. With no tag at all, the first release
|
||||||
|
is `v0.1.0`.
|
||||||
|
|
||||||
|
## 3. Changes since
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git log --format='%h %s%n%b---' <last>..HEAD
|
||||||
|
git diff --stat <last>..HEAD
|
||||||
|
```
|
||||||
|
|
||||||
|
No commits since the last tag: stop, there is nothing to release. Read the
|
||||||
|
commits, and where a subject is not clear, the diff. Sort each change as:
|
||||||
|
|
||||||
|
- **breaking**: removes or changes a key, a command or flag, the on-disk or
|
||||||
|
registry contract (`install.sh`, asset names, `submarine-latest/latest/VERSION`,
|
||||||
|
`submarine update`), or behavior users rely on;
|
||||||
|
- **feature**: a new key, action, view or option, or a visible behavior;
|
||||||
|
- **fix / other**: bug fixes, docs, refactoring, tests, performance.
|
||||||
|
|
||||||
|
## 4. Next version
|
||||||
|
|
||||||
|
| Changes include | Version `0.y.z` (current) | Version `≥ 1.0.0` |
|
||||||
|
|---|---|---|
|
||||||
|
| breaking | minor (`0.y+1.0`) | major |
|
||||||
|
| feature | minor (`0.y+1.0`) | minor |
|
||||||
|
| only fix / other | patch (`0.y.z+1`) | patch |
|
||||||
|
|
||||||
|
submarine is below 1.0: the first release at `1.0.0` is a decision for the
|
||||||
|
user, never made by this skill. Prepare a one-line reason per change and the
|
||||||
|
proposed version.
|
||||||
|
|
||||||
|
## 5. Release check (before anything is tagged)
|
||||||
|
|
||||||
|
All must pass; stop at the first failure and report its output. Nothing has
|
||||||
|
been pushed yet, so there is nothing to undo.
|
||||||
|
|
||||||
|
```sh
|
||||||
|
go vet ./...
|
||||||
|
go test ./...
|
||||||
|
test -z "$(gofmt -l .)"
|
||||||
|
make dist VERSION=vX.Y.Z # all four platforms cross-compile
|
||||||
|
./dist/submarine_linux_amd64 version # prints "submarine vX.Y.Z" (run the one for this machine)
|
||||||
|
make clean
|
||||||
|
```
|
||||||
|
|
||||||
|
Also check by reading: `README.md` and `docs/` mention the new behavior of
|
||||||
|
the features in the changelog (AGENTS.md: keep docs in sync). A missing doc
|
||||||
|
is a finding to report, not a reason to edit during a release; ask the user.
|
||||||
|
|
||||||
|
`make dist` and `make clean` only touch the git-ignored `dist/`.
|
||||||
|
|
||||||
|
## 6. Confirm, then tag and push
|
||||||
|
|
||||||
|
Ask the user (AskUserQuestion) to confirm the version, with the proposal
|
||||||
|
first and marked `(Recommended)`, the other bumps as alternatives. Show: the
|
||||||
|
last version, the changes with their category, the check result, and that
|
||||||
|
the next step creates and pushes the tag. Only after the answer:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
git tag -a vX.Y.Z -m "vX.Y.Z"
|
||||||
|
git push origin vX.Y.Z
|
||||||
|
git ls-remote --tags origin vX.Y.Z # the tag is on the remote
|
||||||
|
```
|
||||||
|
|
||||||
|
If the push fails, report git's message. Do not retry with force. If the tag
|
||||||
|
is wrong after it was pushed, ask before deleting it.
|
||||||
|
|
||||||
|
## 7. Hand over: the developer runs `make release`
|
||||||
|
|
||||||
|
HEAD is now the clean tag, which is what `make release` needs. Tell the
|
||||||
|
developer to run, in the repository:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
make release
|
||||||
|
```
|
||||||
|
|
||||||
|
(it reads `GITEA_TOKEN` from `.env`, tests, builds, uploads, and points
|
||||||
|
`submarine-latest` at the version last). **Stop here and wait** until they
|
||||||
|
say it finished. If it failed, help with the output; do not run it yourself.
|
||||||
|
|
||||||
|
## 8. Verify the upload, create the Gitea release
|
||||||
|
|
||||||
|
Once the developer reports success:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -fsS https://git.simon-franken.de/api/packages/simonfranken/generic/submarine-latest/latest/VERSION
|
||||||
|
```
|
||||||
|
|
||||||
|
must print `vX.Y.Z` (the registry is public; `install.sh` reads it the same
|
||||||
|
way). If it doesn't, the upload did not complete: do not create the release,
|
||||||
|
report it.
|
||||||
|
|
||||||
|
Then create the release with `mcp__gitea__create_release` (load it with
|
||||||
|
ToolSearch first):
|
||||||
|
|
||||||
|
- `owner`: `simonfranken`, `repo`: `submarine`
|
||||||
|
- `tag_name`: `vX.Y.Z`
|
||||||
|
- `target`: the tag's commit, `git rev-parse vX.Y.Z^{commit}`
|
||||||
|
- `title`: `vX.Y.Z`
|
||||||
|
- `is_draft`: false, `is_pre_release`: false
|
||||||
|
- `body`: release notes, see below
|
||||||
|
|
||||||
|
Release notes are for users of the tool, not a commit dump: short, grouped
|
||||||
|
under **Breaking changes** (only if any), **New**, **Fixes** (leave out empty
|
||||||
|
groups and pure refactoring/tests), each item one line naming the key or
|
||||||
|
behavior (`b` switches the branch of a repository...). End with:
|
||||||
|
|
||||||
|
```
|
||||||
|
Install or update: `curl -fsSL https://git.simon-franken.de/simonfranken/submarine/raw/branch/main/install.sh | sh`, or `submarine update`.
|
||||||
|
|
||||||
|
Full changelog: https://git.simon-franken.de/simonfranken/submarine/compare/<last>...vX.Y.Z
|
||||||
|
```
|
||||||
|
|
||||||
|
The binaries live in the package registry, not on the release: attach
|
||||||
|
nothing. Finish with the link to the release and the version.
|
||||||
@@ -22,12 +22,19 @@ go run ./cmd/submarine [dir] # run on a repository (default: cwd)
|
|||||||
go run ./cmd/submarine --mock # run on mock data
|
go run ./cmd/submarine --mock # run on mock data
|
||||||
scripts/make-fixture.sh DIR # build a test superproject in DIR
|
scripts/make-fixture.sh DIR # build a test superproject in DIR
|
||||||
make dist # cross-compile into dist/
|
make dist # cross-compile into dist/
|
||||||
GITEA_TOKEN=… make release # publish a tagged release (outward-facing)
|
GITEA_TOKEN=… make release # publish a tagged release (outward-facing; run by the developer)
|
||||||
go vet ./...
|
go vet ./...
|
||||||
go test ./...
|
go test ./...
|
||||||
gofmt -l . # must print nothing
|
gofmt -l . # must print nothing
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Releasing
|
||||||
|
|
||||||
|
Use the `release` skill (`/release`, `.claude/skills/release/SKILL.md`): it
|
||||||
|
picks the next semver, runs a check, tags and pushes, asks the developer to
|
||||||
|
run `make release`, then creates the Gitea release. Never run `make release`
|
||||||
|
or push tags outside of it.
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
- **Keep docs in sync.** A change to behavior, keys, colors, wording or
|
- **Keep docs in sync.** A change to behavior, keys, colors, wording or
|
||||||
|
|||||||
@@ -27,6 +27,9 @@ the questions you actually have:
|
|||||||
- **Live.** Follows changes you make in other shells within about two
|
- **Live.** Follows changes you make in other shells within about two
|
||||||
seconds. It never fetches on its own: the header shows when you last did,
|
seconds. It never fetches on its own: the header shows when you last did,
|
||||||
`f` fetches everything.
|
`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.
|
||||||
- **Built for dozens to hundreds of submodules.** Each status loads
|
- **Built for dozens to hundreds of submodules.** Each status loads
|
||||||
asynchronously, so the UI never freezes.
|
asynchronously, so the UI never freezes.
|
||||||
|
|
||||||
@@ -94,6 +97,44 @@ Select a submodule and press `c`. Its absolute directory lands in your
|
|||||||
clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
|
clipboard, ready for `cd <paste>`. It works over SSH too (OSC 52; in tmux,
|
||||||
`set -g set-clipboard on`).
|
`set -g set-clipboard on`).
|
||||||
|
|
||||||
|
### Restore with `x`
|
||||||
|
|
||||||
|
Select a submodule (or the header for the whole superproject) and press `x`.
|
||||||
|
Its worktree becomes exactly what git records, and so do all submodules
|
||||||
|
below it: changes are reset, untracked files removed, merges and rebases
|
||||||
|
ended, missing submodules cloned. Staged pins are kept. If something would be
|
||||||
|
lost, you are asked first:
|
||||||
|
|
||||||
|
```
|
||||||
|
Restore src/app? Discards 7 changed files in 3 repositories. y restore · n cancel
|
||||||
|
```
|
||||||
|
|
||||||
|
`y` restores, any other key cancels. Without a loss it runs at once. For a
|
||||||
|
submodule with a staged pin, `x` first asks whether to restore to the staged
|
||||||
|
pin (`s`) or the committed one (`c`, which unstages it).
|
||||||
|
|
||||||
|
### Switch a branch with `b`
|
||||||
|
|
||||||
|
Press `b` on a submodule (or the header for the superproject). A popup lists
|
||||||
|
the local and remote branches, newest first; type to filter.
|
||||||
|
|
||||||
|
```
|
||||||
|
╭──────────────────────────────────────────────────────────╮
|
||||||
|
│ Switch libs/feature │
|
||||||
|
│ / sea │
|
||||||
|
│ ──────────────────────────────────────────────────────── │
|
||||||
|
│ origin/feat/search 58a2bf1 2h ago │
|
||||||
|
│ ↑↓ select · enter switch · esc cancel │
|
||||||
|
╰──────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
`↑` `↓` 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
|
||||||
|
first if work would be lost. The list is read from disk, so press `f` first
|
||||||
|
to see new remote branches.
|
||||||
|
|
||||||
### Fetch and reload
|
### Fetch and reload
|
||||||
|
|
||||||
`f` fetches all repositories again, `r` reloads everything after you edited
|
`f` fetches all repositories again, `r` reloads everything after you edited
|
||||||
@@ -111,17 +152,20 @@ files.
|
|||||||
| `/` | Filter |
|
| `/` | Filter |
|
||||||
| `!` | Problems only |
|
| `!` | Problems only |
|
||||||
| `c` | Copy path |
|
| `c` | Copy path |
|
||||||
|
| `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: make the worktree exactly what git records (asks if work would be lost) |
|
||||||
| `f` / `r` | Fetch / reload |
|
| `f` / `r` | Fetch / reload |
|
||||||
| `?` | Full help |
|
| `?` | Full help |
|
||||||
| `q` | Quit |
|
| `q` | Quit |
|
||||||
|
|
||||||
The mouse works too: click to select, click `▸` to toggle, wheel to move.
|
The mouse works too: click to select, click `▸` to toggle, wheel to move. In
|
||||||
|
the branch popup, click a branch to switch to it, use the wheel to move, and
|
||||||
|
click outside to close it.
|
||||||
|
|
||||||
## Status
|
## Status
|
||||||
|
|
||||||
A prototype. Browsing is complete, and `x` restores a submodule or the whole
|
A prototype. Browsing is complete. Two write actions exist: restore (`x`)
|
||||||
superproject. More write actions (stage pin) are being specified in
|
and switch branch (`b`). More (stage pin) are being specified in
|
||||||
[docs/actions.md](docs/actions.md).
|
[docs/actions.md](docs/actions.md).
|
||||||
|
|
||||||
## Documentation
|
## Documentation
|
||||||
|
|||||||
+4
-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 |
|
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs |
|
||||||
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
|
| [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings |
|
||||||
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
|
| [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing |
|
||||||
| [actions.md](actions.md) | **Draft:** the write actions restore and stage pin |
|
| [actions.md](actions.md) | the write actions restore and switch branch (done) and stage pin (draft) |
|
||||||
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
|
| [decisions.md](decisions.md) | Decision log with rationale (ADR style) |
|
||||||
|
|
||||||
## Install
|
## Install
|
||||||
@@ -33,10 +33,10 @@ Installs `submarine` and the alias `sub` (linux/macOS, amd64/arm64) to
|
|||||||
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
|
through a git provider (`internal/submodule/git`): `submarine [dir]`. It
|
||||||
fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows
|
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
|
the built-in mock data (`internal/submodule/mock`). `!` shows only
|
||||||
submodules with problems. `x` restores the selection
|
submodules with problems. `x` restores the selection and `b` switches its
|
||||||
([actions.md](actions.md)); stage pin is drafted there.
|
branch ([actions.md](actions.md)); stage pin is drafted there.
|
||||||
|
|
||||||
Next steps, roughly in order:
|
Next steps, roughly in order:
|
||||||
|
|
||||||
1. Stage pin, see [actions.md](actions.md). (Restore is done.)
|
1. Stage pin, see [actions.md](actions.md). (Restore and switch branch are done.)
|
||||||
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
|
2. (done) "Problems only" (`!`) view, reusing the tree's filter.
|
||||||
|
|||||||
+82
-5
@@ -1,7 +1,7 @@
|
|||||||
# Actions (draft)
|
# Actions (draft)
|
||||||
|
|
||||||
**Status:** [Restore](#restore) is implemented (key `x`, decisions in
|
**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and
|
||||||
[D23](decisions.md)). [Stage pin](#stage-pin) is still a draft; its open
|
[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).
|
questions are [at the end](#open-questions).
|
||||||
|
|
||||||
## Principle
|
## Principle
|
||||||
@@ -14,10 +14,11 @@ a difference in one of two directions:
|
|||||||
|---|---|---|
|
|---|---|---|
|
||||||
| [Restore](#restore) | The worktree follows the recorded state | `x` |
|
| [Restore](#restore) | The worktree follows the recorded state | `x` |
|
||||||
| [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` |
|
| [Stage pin](#stage-pin) | The recorded state follows the worktree | `s` |
|
||||||
|
| [Switch branch](#switch-branch) | Both: the branch tip is recorded, then restored to | `b` |
|
||||||
|
|
||||||
There is nothing else for now. Committing, pushing, switching branches and
|
There is nothing else for now. Committing, pushing and bumping a pin to the
|
||||||
bumping a pin to the tip are done outside submarine. A bump is "check out
|
tip are done outside submarine. A bump is "check out the tip in the
|
||||||
the tip in the submodule, then `s`".
|
submodule, then `s`" (or switch to the branch with `b`).
|
||||||
|
|
||||||
## Restore
|
## Restore
|
||||||
|
|
||||||
@@ -196,6 +197,74 @@ target. A directory in the untracked list is searched for `.git`
|
|||||||
*directories* (a standalone repository); an absorbed submodule leaves only a
|
*directories* (a standalone repository); an absorbed submodule leaves only a
|
||||||
`.git` file and loses just its worktree.
|
`.git` file and loses just its worktree.
|
||||||
|
|
||||||
|
## Switch branch
|
||||||
|
|
||||||
|
Moves the selected repository to another branch, in one step: the tip of the
|
||||||
|
branch becomes the pin, and the repository is restored to it.
|
||||||
|
|
||||||
|
### The popup
|
||||||
|
|
||||||
|
`b` opens a popup in the middle of the screen: a search line on top, below it
|
||||||
|
the branches of the selection, local ones first, then remote ones
|
||||||
|
(`origin/…`), each group with the newest tip first. Each row shows the name
|
||||||
|
(`*` marks the checked-out branch), the short SHA and the age of the tip.
|
||||||
|
The list is read from the disk; fetch (`f`) is what brings new remote
|
||||||
|
branches.
|
||||||
|
|
||||||
|
| Key | Effect |
|
||||||
|
|---|---|
|
||||||
|
| text | Filters: every word must be in the name, case-insensitive. The selection stays on its branch if it still matches |
|
||||||
|
| `↑` `↓`, `pgup` `pgdn` | Select |
|
||||||
|
| `enter` | Switch to the selected branch |
|
||||||
|
| `esc` | Close |
|
||||||
|
|
||||||
|
Mouse: a click on a branch switches to it (like `enter`), the wheel moves the
|
||||||
|
selection, a click outside the popup closes it; a click on the title, the
|
||||||
|
search line or an empty row does nothing. Everything else is ignored while the
|
||||||
|
popup is open. A header selection switches the superproject.
|
||||||
|
|
||||||
|
### Effect
|
||||||
|
|
||||||
|
For a **submodule**, with the tip `T` the list showed:
|
||||||
|
|
||||||
|
1. The parent's index records `T` as the pin (a staged pin; if `T` is the
|
||||||
|
committed pin, the index equals `HEAD` again).
|
||||||
|
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`.
|
||||||
|
|
||||||
|
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
|
||||||
|
the old state are lost, and counted).
|
||||||
|
|
||||||
|
Staging comes first, so a failed restore can be repeated with `x`. If the
|
||||||
|
branch moved since the list was read (a fetch), the switch aborts with
|
||||||
|
`origin/feat moved; open the list again`; the staged SHA is always the one
|
||||||
|
shown.
|
||||||
|
|
||||||
|
### Confirmation, network, errors
|
||||||
|
|
||||||
|
As for restore: the loss is read from the disk and the question
|
||||||
|
(`Switch src/app to origin/feat? Discards … y switch · n cancel`) appears
|
||||||
|
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
|
||||||
|
`switch of src/app to origin/feat failed: …`, until the next key. One
|
||||||
|
restore or switch runs at a time.
|
||||||
|
|
||||||
|
### Implementation note
|
||||||
|
|
||||||
|
`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>`.
|
||||||
|
`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one
|
||||||
|
`for-each-ref`, without the `origin/HEAD` symref.
|
||||||
|
|
||||||
## Stage pin
|
## Stage pin
|
||||||
|
|
||||||
Records the commit checked out in a submodule as its new pin in the
|
Records the commit checked out in a submodule as its new pin in the
|
||||||
@@ -254,6 +323,7 @@ offered: it is idempotent, and the palette is static.
|
|||||||
| Key | Action | Pressed when not possible (flash) |
|
| 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 | Restore the selection | `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` |
|
| `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` |
|
||||||
|
|
||||||
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
|
`R` would sit next to `r` (reload), a dangerous mix-up for a destructive
|
||||||
@@ -272,6 +342,13 @@ action, hence `x`.
|
|||||||
detached commits (not those on a branch) and standalone repositories.
|
detached commits (not those on a branch) and standalone repositories.
|
||||||
- UI (`internal/tui/restore_test.go`): restores at once without a loss, asks
|
- 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.
|
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
|
||||||
|
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.
|
||||||
- By hand in tmux on a fixture, see
|
- By hand in tmux on a fixture, see
|
||||||
[architecture.md](architecture.md#testing).
|
[architecture.md](architecture.md#testing).
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -12,25 +12,30 @@ cmd/submarine/main.go wiring: choose provider (git or --mock), start the
|
|||||||
internal/submodule/ domain model; NO UI imports
|
internal/submodule/ domain model; NO UI imports
|
||||||
model.go Repo, Submodule, Status, PinInfo, Commit, enums
|
model.go Repo, Submodule, Status, PinInfo, Commit, enums
|
||||||
status.go Issues(), HiddenIssues(), Walk()
|
status.go Issues(), HiddenIssues(), Walk()
|
||||||
provider.go Provider interface
|
provider.go Provider interface; optional Fetcher, Watcher, Locator, Restorer, Switcher
|
||||||
internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
|
internal/submodule/git/ provider using the git CLI (reads, fetch, watch)
|
||||||
exec.go git runner (environment, errors)
|
exec.go git runner (environment, errors)
|
||||||
parse.go pure parsers for git output (status, numstat, …)
|
parse.go pure parsers for git output (status, numstat, …)
|
||||||
provider.go Tree, Status, pin → ref resolution
|
provider.go Tree, Status, pin → ref resolution
|
||||||
fetch.go Fetch (network), Fetchable
|
fetch.go Fetch (network), Fetchable
|
||||||
watch.go Watch: polls git metadata for changes
|
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)
|
||||||
gitdir.go resolves .git dirs and gitdir: files
|
gitdir.go resolves .git dirs and gitdir: files
|
||||||
internal/submodule/mock/ fixture provider with artificial latency
|
internal/submodule/mock/ fixture provider with artificial latency
|
||||||
internal/update/ self-update from the package registry; NO UI imports
|
internal/update/ self-update from the package registry; NO UI imports
|
||||||
internal/tui/ Bubble Tea application
|
internal/tui/ Bubble Tea application
|
||||||
app.go root model: layout, message routing, loading
|
app.go root model: layout, message routing, loading
|
||||||
keys.go key bindings (also feed the help view)
|
keys.go key bindings (also feed the help view)
|
||||||
|
restore.go, switch.go restore / switch flows; a switch reuses restoreState
|
||||||
|
overlay.go draws the picker over the finished view at the position the picker reports
|
||||||
msgs.go tree/status/fetch/change messages
|
msgs.go tree/status/fetch/change messages
|
||||||
refresh.go reloader: one load per key, coalesced reruns
|
refresh.go reloader: one load per key, coalesced reruns
|
||||||
theme/ colors, glyphs, issue words, change letters, level styles
|
theme/ colors, glyphs, issue words, change letters, level styles
|
||||||
tree/ tree component: rows, navigation, mouse, view
|
tree/ tree component: rows, navigation, mouse, view
|
||||||
detail/ detail pane; changes.go: change preview
|
detail/ detail pane; changes.go: change preview
|
||||||
header/ header line
|
header/ header line
|
||||||
|
branches/ branch picker popup (search line + list)
|
||||||
footer/ key bar (navigation left, global right) and flash messages
|
footer/ key bar (navigation left, global right) and flash messages
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -71,6 +76,14 @@ type Restorer interface {
|
|||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
```go
|
||||||
|
type Switcher interface {
|
||||||
|
Branches(ctx context.Context, path string) ([]Branch, error) // local + remote, no network
|
||||||
|
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error) // as RestoreLoss
|
||||||
|
Switch(ctx context.Context, path string, b Branch) (string, error) // branch HEAD ends on, "" if detached
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
```go
|
```go
|
||||||
type Locator interface {
|
type Locator interface {
|
||||||
Dir(path string) string // absolute directory, "" if unknown
|
Dir(path string) string // absolute directory, "" if unknown
|
||||||
@@ -194,6 +207,11 @@ No CI runner: releases are built and published from a developer machine.
|
|||||||
- `submarine/<version>/submarine_<os>_<arch>` and `checksums.txt`
|
- `submarine/<version>/submarine_<os>_<arch>` and `checksums.txt`
|
||||||
- `submarine-latest/latest/VERSION`, replaced **last**, so a failed upload
|
- `submarine-latest/latest/VERSION`, replaced **last**, so a failed upload
|
||||||
never advertises a broken version.
|
never advertises a broken version.
|
||||||
|
- The `release` skill (`.claude/skills/release/SKILL.md`) drives a release:
|
||||||
|
latest tag → next semver from the commits since → check (`vet`, `test`,
|
||||||
|
`gofmt`, `make dist`) → tag and push → the developer runs `make release` →
|
||||||
|
the skill verifies the registry's `VERSION` and creates the Gitea release
|
||||||
|
with MCP (notes only; the binaries stay in the registry).
|
||||||
- `install.sh` (`curl -fsSL …/raw/branch/main/install.sh | sh`) detects
|
- `install.sh` (`curl -fsSL …/raw/branch/main/install.sh | sh`) detects
|
||||||
OS/arch, verifies the sha256, installs `submarine` and a `sub` symlink to
|
OS/arch, verifies the sha256, installs `submarine` and a `sub` symlink to
|
||||||
`~/.local/bin` (`SUBMARINE_INSTALL_DIR`, `SUBMARINE_VERSION`,
|
`~/.local/bin` (`SUBMARINE_INSTALL_DIR`, `SUBMARINE_VERSION`,
|
||||||
|
|||||||
@@ -349,3 +349,40 @@ the same for every submodule of the target tree, initializing missing ones.
|
|||||||
does not implement it. Restore is idempotent and can be repeated after a
|
does not implement it. Restore is idempotent and can be repeated after a
|
||||||
failure. It takes the time of its slowest clone; there is no per-repository
|
failure. It takes the time of its slowest clone; there is no per-repository
|
||||||
progress yet.
|
progress yet.
|
||||||
|
|
||||||
|
## D24 — Switch branch: stage the tip, then restore
|
||||||
|
|
||||||
|
**Context.** Switching branches was left to the shell. Users of a repository
|
||||||
|
with many submodules want to move one to another branch and have the
|
||||||
|
superproject record it.
|
||||||
|
**Decision.** `b` opens a popup (search line, local then remote branches).
|
||||||
|
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.
|
||||||
|
- *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).
|
||||||
|
- *Same loss rules as restore.* The loss is read from the disk; the question
|
||||||
|
appears only if something is lost. Staged first, so a failed restore can be
|
||||||
|
repeated with `x`. The pin question of restore (`s` / `c`) is skipped: the
|
||||||
|
staged pin is being replaced.
|
||||||
|
- *What the picker showed is what is staged.* The branch carries its SHA; a
|
||||||
|
fetch may move the ref meanwhile, then the switch aborts
|
||||||
|
(`origin/feat moved; open the list again`).
|
||||||
|
- *Network.* Extends D23: a tip often pins nested commits that are not on
|
||||||
|
disk, so switch may clone and fetch what is missing, like restore. The
|
||||||
|
list itself uses none; `f` refreshes remote branches.
|
||||||
|
- *Popup.* The first overlay: drawn over the finished view (after
|
||||||
|
`zone.Scan`, so no zone marker is cut); the keys go to the picker while it
|
||||||
|
is open, and so does the mouse: the popup remembers where it was drawn and
|
||||||
|
tests clicks itself (bubblezone can't see it, it is drawn after the scan).
|
||||||
|
A click on a branch switches, like `enter`; a click outside closes. The backdrop is dimmed: stripped of its styles and drawn in
|
||||||
|
`Backdrop`, an adaptive 256-color grey (238 dark / 252 light). `Muted`
|
||||||
|
(ANSI 8) was too bright to set the popup apart, and ANSI faint (SGR 2) is
|
||||||
|
ignored by some terminals. Like `Bar` and `Selected` it carries no meaning,
|
||||||
|
so it may leave the 16 ANSI colors.
|
||||||
|
**Consequences.** `Switcher` is a new optional provider interface; the mock
|
||||||
|
does not implement it. The switch runs as the restore does (one write
|
||||||
|
operation at a time, same lines above the footer).
|
||||||
|
|||||||
Binary file not shown.
|
Before Width: | Height: | Size: 127 KiB After Width: | Height: | Size: 420 KiB |
@@ -137,6 +137,13 @@ commit hashes. It doesn't answer the questions users actually have:
|
|||||||
staged or the committed pin (the latter unstages it). It asks for
|
staged or the committed pin (the latter unstages it). It asks for
|
||||||
confirmation only if something would be lost. See [actions.md](actions.md#restore).
|
confirmation only if something would be lost. See [actions.md](actions.md#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
|
||||||
|
superproject). `↑` / `↓` select, `enter` switches, `esc` cancels. For a
|
||||||
|
submodule the branch tip becomes its staged pin and it is restored to it,
|
||||||
|
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).
|
Stage pin is drafted in [actions.md](actions.md#stage-pin).
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
@@ -161,7 +168,7 @@ submarine --mock # built-in mock data
|
|||||||
- **N7** The UI is independent of the data source: the mock and the git
|
- **N7** The UI is independent of the data source: the mock and the git
|
||||||
provider are interchangeable without UI changes.
|
provider are interchangeable without UI changes.
|
||||||
- **N8** The git provider only uses the network in `Fetch` and, for objects
|
- **N8** The git provider only uses the network in `Fetch` and, for objects
|
||||||
that are missing, in `Restore`. It never prompts (no password, passphrase
|
that are missing, in `Restore` and `Switch`. It never prompts (no password, passphrase
|
||||||
or host-key questions; those fail with git's message). It uses the `git`
|
or host-key questions; those fail with git's message). It uses the `git`
|
||||||
command line, so the user's git configuration applies. Apart from fetching
|
command line, so the user's git configuration applies. Apart from fetching
|
||||||
and the actions of [actions.md](actions.md), it is read-only: it writes
|
and the actions of [actions.md](actions.md), it is read-only: it writes
|
||||||
|
|||||||
@@ -183,6 +183,7 @@ colors in components.
|
|||||||
| Border | ANSI `8` grey | pane and help separators |
|
| Border | ANSI `8` grey | pane and help separators |
|
||||||
| Bar | adaptive 254 (light) / 235 (dark) | header background |
|
| Bar | adaptive 254 (light) / 235 (dark) | header background |
|
||||||
| Selected | adaptive 253 (light) / 237 (dark) | selected row background |
|
| Selected | adaptive 253 (light) / 237 (dark) | selected row background |
|
||||||
|
| Backdrop | adaptive 252 (light) / 238 (dark) | the screen behind a popup |
|
||||||
|
|
||||||
- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`.
|
- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`.
|
||||||
The background is detected once in `main` before Bubble Tea starts.
|
The background is detected once in `main` before Bubble Tea starts.
|
||||||
@@ -293,6 +294,34 @@ question is open. After `c` the name reads `src/app to the committed pin`. Witho
|
|||||||
superproject`. This line takes precedence over the filter line and the
|
superproject`. This line takes precedence over the filter line and the
|
||||||
problems-only notice.
|
problems-only notice.
|
||||||
|
|
||||||
|
### Switch branch
|
||||||
|
|
||||||
|
`b` opens a popup in the middle of the screen (see
|
||||||
|
[actions.md](actions.md#switch-branch)). The screen around it stays visible
|
||||||
|
but dimmed: its colors are dropped and it is drawn in `Backdrop`, darker
|
||||||
|
than `Muted`, so the popup has the focus.
|
||||||
|
|
||||||
|
```
|
||||||
|
╭──────────────────────────────────────────────────────────────────────╮
|
||||||
|
│ Switch libs/feature │
|
||||||
|
│ / filter branches │
|
||||||
|
│ ──────────────────────────────────────────────────────────────────── │
|
||||||
|
│ * main 92d1583 3d ago │
|
||||||
|
│ origin/feat/search 58a2bf1 2h ago │
|
||||||
|
│ ↑↓ select · enter switch · esc cancel │
|
||||||
|
╰──────────────────────────────────────────────────────────────────────╯
|
||||||
|
```
|
||||||
|
|
||||||
|
- Border `Border`, title in `Primary` bold, search prompt `Primary`, the
|
||||||
|
selected row on the `Selected` background, SHA and age `Muted`, the remote
|
||||||
|
prefix `Muted`, the checked-out branch bold with `*`.
|
||||||
|
- Width at most 72 columns, height as the list (at most 14 rows), so
|
||||||
|
filtering does not resize it. While reading: spinner and `reading
|
||||||
|
branches…`; no match: `no branch matches`; error: red, inside the popup.
|
||||||
|
- Enter closes the popup and continues on the line above the footer like a
|
||||||
|
[restore](#restore): `checking`, the question `Switch src/app to
|
||||||
|
origin/feat? Discards … y switch · n cancel`, `switching …`.
|
||||||
|
|
||||||
### Key reference
|
### Key reference
|
||||||
|
|
||||||
Every key in the UI, where it is shown. Update this
|
Every key in the UI, where it is shown. Update this
|
||||||
@@ -310,6 +339,7 @@ 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 |
|
| `home` / `g`, `end` / `G` | Header / last submodule | Navigation | Help |
|
||||||
| `pgup`, `pgdn` | Move by one screen | Navigation | Help |
|
| `pgup`, `pgdn` | Move by one screen | Navigation | Help |
|
||||||
| `c` | Copy path | Submodule | Palette, Help |
|
| `c` | Copy path | Submodule | Palette, Help |
|
||||||
|
| `b` | Switch branch (popup) | Submodule | Palette, Help |
|
||||||
| `x` | Restore | Submodule | Palette, Help |
|
| `x` | Restore | Submodule | Palette, Help |
|
||||||
| `f` | Fetch all repositories | Global | Help |
|
| `f` | Fetch all repositories | Global | Help |
|
||||||
| `r` | Reload everything | Global | Help |
|
| `r` | Reload everything | Global | Help |
|
||||||
@@ -323,6 +353,9 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette
|
|||||||
- Click a row: select it. Click the header: select the superproject.
|
- Click a row: select it. Click the header: select the superproject.
|
||||||
- Click `▸` / `▾`: select and toggle.
|
- Click `▸` / `▾`: select and toggle.
|
||||||
- Scroll wheel: move the selection.
|
- Scroll wheel: move the selection.
|
||||||
|
- In the [branch popup](#switch-branch): click a branch to switch to it,
|
||||||
|
wheel to move, click outside to close. The popup is hit-tested by its own
|
||||||
|
position, not with bubblezone, since it is drawn after `zone.Scan`.
|
||||||
|
|
||||||
## Accessibility and terminals
|
## Accessibility and terminals
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,132 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"errors"
|
||||||
|
"fmt"
|
||||||
|
"slices"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
// switchDir returns the directory of path ("" is the superproject), and the
|
||||||
|
// submodule's info if it is one.
|
||||||
|
func (p *Provider) switchDir(path string) (dir string, in info, sub bool, err error) {
|
||||||
|
if path == "" {
|
||||||
|
return p.root, info{}, false, nil
|
||||||
|
}
|
||||||
|
p.mu.RLock()
|
||||||
|
in, ok := p.mods[path]
|
||||||
|
p.mu.RUnlock()
|
||||||
|
if !ok {
|
||||||
|
return "", info{}, false, fmt.Errorf("unknown submodule %q", path)
|
||||||
|
}
|
||||||
|
if !initialized(in.dir) {
|
||||||
|
return "", info{}, false, errors.New("not initialized; restore it first")
|
||||||
|
}
|
||||||
|
return in.dir, in, true, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Branches lists the local and remote-tracking branches of path, local ones
|
||||||
|
// first, each group by the date of its tip, newest first. It reads only what
|
||||||
|
// is on disk.
|
||||||
|
func (p *Provider) Branches(ctx context.Context, path string) ([]submodule.Branch, error) {
|
||||||
|
dir, _, _, err := p.switchDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out, err := run(ctx, dir, "for-each-ref",
|
||||||
|
"--format=%(refname)%00%(objectname)%00%(committerdate:unix)%00%(HEAD)%00%(symref)",
|
||||||
|
"refs/heads", "refs/remotes")
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
var branches []submodule.Branch
|
||||||
|
for _, line := range strings.Split(out, "\n") {
|
||||||
|
f := strings.Split(line, "\x00")
|
||||||
|
if len(f) != 5 || f[4] != "" { // a symref is origin/HEAD
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
b := submodule.Branch{Ref: f[0], SHA: f[1], Current: f[3] == "*"}
|
||||||
|
if secs, err := strconv.ParseInt(f[2], 10, 64); err == nil {
|
||||||
|
b.When = time.Unix(secs, 0)
|
||||||
|
}
|
||||||
|
if name, ok := strings.CutPrefix(f[0], "refs/heads/"); ok {
|
||||||
|
b.Name, b.Local = name, name
|
||||||
|
} else if name, ok := strings.CutPrefix(f[0], "refs/remotes/"); ok {
|
||||||
|
_, local, _ := strings.Cut(name, "/")
|
||||||
|
b.Name, b.Local, b.Remote = name, local, true
|
||||||
|
} else {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
branches = append(branches, b)
|
||||||
|
}
|
||||||
|
slices.SortStableFunc(branches, func(a, b submodule.Branch) int {
|
||||||
|
switch {
|
||||||
|
case a.Remote != b.Remote:
|
||||||
|
if a.Remote {
|
||||||
|
return 1
|
||||||
|
}
|
||||||
|
return -1
|
||||||
|
default:
|
||||||
|
return b.When.Compare(a.When)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return branches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SwitchLoss reports what switching path to b would discard: the same as a
|
||||||
|
// restore to b's tip.
|
||||||
|
func (p *Provider) SwitchLoss(ctx context.Context, path string, b submodule.Branch) (submodule.Loss, error) {
|
||||||
|
dir, _, _, err := p.switchDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return submodule.Loss{}, err
|
||||||
|
}
|
||||||
|
return p.lossOf(ctx, dir, b.SHA), 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.
|
||||||
|
func (p *Provider) Switch(ctx context.Context, path string, b submodule.Branch) (string, error) {
|
||||||
|
dir, in, sub, err := p.switchDir(path)
|
||||||
|
if err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
if sha, ok, err := try(ctx, dir, "rev-parse", "--verify", "-q", b.Ref+"^{commit}"); err != nil {
|
||||||
|
return "", err
|
||||||
|
} else if !ok || sha != b.SHA {
|
||||||
|
return "", fmt.Errorf("%s moved; open the list again", b.Name)
|
||||||
|
}
|
||||||
|
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); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
|
||||||
|
g := func(args ...string) (string, error) {
|
||||||
|
return p.exec(ctx, dir, nil, append(append([]string{}, restoreArgs...), args...)...)
|
||||||
|
}
|
||||||
|
if !b.Remote {
|
||||||
|
_, 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); {
|
||||||
|
case err != nil:
|
||||||
|
return "", err
|
||||||
|
case !ok:
|
||||||
|
_, err := g("switch", "-q", "-c", b.Local, "--track", b.Name)
|
||||||
|
return b.Local, err
|
||||||
|
case tip == b.SHA:
|
||||||
|
_, err := g("switch", "-q", b.Local)
|
||||||
|
return b.Local, err
|
||||||
|
}
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,181 @@
|
|||||||
|
package git
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"os"
|
||||||
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
)
|
||||||
|
|
||||||
|
func findBranch(t *testing.T, bs []submodule.Branch, name string) submodule.Branch {
|
||||||
|
t.Helper()
|
||||||
|
for _, b := range bs {
|
||||||
|
if b.Name == name {
|
||||||
|
return b
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Fatalf("no branch %q in %v", name, bs)
|
||||||
|
return submodule.Branch{}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchesListsLocalAndRemote(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
_ = root
|
||||||
|
bs, err := p.Branches(context.Background(), "libs/feature")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
var names []string
|
||||||
|
seenRemote := false
|
||||||
|
for _, b := range bs {
|
||||||
|
names = append(names, b.Name)
|
||||||
|
if b.Remote {
|
||||||
|
seenRemote = true
|
||||||
|
} else if seenRemote {
|
||||||
|
t.Errorf("local branch %s after a remote one: %v", b.Name, names)
|
||||||
|
}
|
||||||
|
if b.Name == "origin/HEAD" {
|
||||||
|
t.Error("origin/HEAD must not be listed")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rb := findBranch(t, bs, "origin/feat/search")
|
||||||
|
if !rb.Remote || rb.Local != "feat/search" {
|
||||||
|
t.Errorf("origin/feat/search = %+v", rb)
|
||||||
|
}
|
||||||
|
findBranch(t, bs, "origin/main")
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchSubmoduleStagesTipAndTracksRemoteBranch(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
sub := filepath.Join(super, "libs", "feature")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
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" {
|
||||||
|
t.Errorf("Switch returned %q, want feat/search", got)
|
||||||
|
}
|
||||||
|
if head := out(t, sub, "branch", "--show-current"); head != "feat/search" {
|
||||||
|
t.Errorf("branch = %q", head)
|
||||||
|
}
|
||||||
|
if up := out(t, sub, "rev-parse", "--abbrev-ref", "feat/search@{upstream}"); up != "origin/feat/search" {
|
||||||
|
t.Errorf("upstream = %q", up)
|
||||||
|
}
|
||||||
|
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
|
||||||
|
t.Errorf("HEAD = %s, want %s", head, b.SHA)
|
||||||
|
}
|
||||||
|
if staged := out(t, super, "ls-files", "-s", "--", "libs/feature"); !strings.Contains(staged, b.SHA) {
|
||||||
|
t.Errorf("index = %q, want the tip %s staged", staged, b.SHA)
|
||||||
|
}
|
||||||
|
assertExact(t, sub)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchNeverMovesAnExistingLocalBranch(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
|
||||||
|
before := out(t, sub, "rev-parse", "feat/search")
|
||||||
|
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 != "" {
|
||||||
|
t.Errorf("Switch returned %q, want detached", got)
|
||||||
|
}
|
||||||
|
if head := out(t, sub, "branch", "--show-current"); head != "" {
|
||||||
|
t.Errorf("HEAD on %q, want detached", head)
|
||||||
|
}
|
||||||
|
if after := out(t, sub, "rev-parse", "feat/search"); after != before {
|
||||||
|
t.Errorf("branch moved: %s -> %s", before, after)
|
||||||
|
}
|
||||||
|
if head := out(t, sub, "rev-parse", "HEAD"); head != b.SHA {
|
||||||
|
t.Errorf("HEAD = %s, want %s", head, b.SHA)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchAbortsWhenTheBranchMoved(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
sub := filepath.Join(root, "super", "libs", "feature")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bs, err := p.Branches(ctx, "libs/feature")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := findBranch(t, bs, "origin/main")
|
||||||
|
b.SHA = out(t, sub, "rev-parse", "origin/feat/search") // what the list showed is stale
|
||||||
|
if _, err := p.Switch(ctx, "libs/feature", b); err == nil || !strings.Contains(err.Error(), "moved") {
|
||||||
|
t.Errorf("err = %v, want a moved error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchSuperprojectRestoresSubmodulesToItsPins(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
super := filepath.Join(root, "super")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
git(t, super, "branch", "older", "HEAD~1")
|
||||||
|
bs, err := p.Branches(ctx, "")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := findBranch(t, bs, "older")
|
||||||
|
got, err := p.Switch(ctx, "", b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if got != "older" || out(t, super, "branch", "--show-current") != "older" {
|
||||||
|
t.Errorf("Switch = %q, branch = %q", got, out(t, super, "branch", "--show-current"))
|
||||||
|
}
|
||||||
|
assertExact(t, super)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchLossCountsChangedFiles(t *testing.T) {
|
||||||
|
p, root := ownFixture(t)
|
||||||
|
sub := filepath.Join(root, "super", "libs", "feature")
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
bs, err := p.Branches(ctx, "libs/feature")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
b := findBranch(t, bs, "origin/feat/search")
|
||||||
|
loss, err := p.SwitchLoss(ctx, "libs/feature", b)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if !loss.Empty() {
|
||||||
|
t.Errorf("clean checkout: loss = %+v", loss)
|
||||||
|
}
|
||||||
|
if err := os.WriteFile(filepath.Join(sub, "new.txt"), []byte("x"), 0o644); err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
if loss, err = p.SwitchLoss(ctx, "libs/feature", b); err != nil || loss.Files != 1 {
|
||||||
|
t.Errorf("loss = %+v, %v; want 1 file", loss, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestBranchesOfAMissingSubmoduleSaysSo(t *testing.T) {
|
||||||
|
p, _ := ownFixture(t)
|
||||||
|
if _, err := p.Branches(context.Background(), "libs/missing"); err == nil || !strings.Contains(err.Error(), "not initialized") {
|
||||||
|
t.Errorf("err = %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,9 @@
|
|||||||
package submodule
|
package submodule
|
||||||
|
|
||||||
import "context"
|
import (
|
||||||
|
"context"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
// Provider supplies repository data. Tree is expected to be fast (structure
|
// Provider supplies repository data. Tree is expected to be fast (structure
|
||||||
// only); Status may be slow and is called once per submodule, concurrently.
|
// only); Status may be slow and is called once per submodule, concurrently.
|
||||||
@@ -44,6 +47,31 @@ type Restorer interface {
|
|||||||
Restore(ctx context.Context, path string, to RestoreTarget) error
|
Restore(ctx context.Context, path string, to RestoreTarget) error
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Branch is a local or remote-tracking branch of a repository.
|
||||||
|
type Branch struct {
|
||||||
|
Ref string // full ref, e.g. "refs/remotes/origin/feat"
|
||||||
|
Name string // short name, e.g. "origin/feat"
|
||||||
|
Local string // name of the local branch it stands for, e.g. "feat"
|
||||||
|
Remote bool // a remote-tracking branch
|
||||||
|
Current bool // HEAD is on this branch
|
||||||
|
SHA string // the tip when the list was read
|
||||||
|
When time.Time // committer date of the tip
|
||||||
|
}
|
||||||
|
|
||||||
|
// Switcher is implemented by providers that can switch a repository to a
|
||||||
|
// branch: the tip becomes the staged pin of a submodule, and the repository
|
||||||
|
// (with everything below it) is restored to it. Path "" is the superproject.
|
||||||
|
// Like Restore, Switch may clone or fetch what is missing below, but must
|
||||||
|
// never prompt the user. Branches uses no network.
|
||||||
|
type Switcher interface {
|
||||||
|
Branches(ctx context.Context, path string) ([]Branch, error)
|
||||||
|
// SwitchLoss reports what Switch would discard, read from the disk now.
|
||||||
|
SwitchLoss(ctx context.Context, path string, b Branch) (Loss, error)
|
||||||
|
// Switch returns the branch HEAD ends up on, "" if it is detached. It
|
||||||
|
// fails if b.Ref no longer points at b.SHA.
|
||||||
|
Switch(ctx context.Context, path string, b Branch) (string, error)
|
||||||
|
}
|
||||||
|
|
||||||
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
|
// RestoreTarget is the pin a submodule is restored to. It doesn't matter for
|
||||||
// the superproject, whose target is always HEAD.
|
// the superproject, whose target is always HEAD.
|
||||||
type RestoreTarget int
|
type RestoreTarget int
|
||||||
|
|||||||
+24
-4
@@ -15,6 +15,7 @@ import (
|
|||||||
zone "github.com/lrstanley/bubblezone"
|
zone "github.com/lrstanley/bubblezone"
|
||||||
|
|
||||||
"submarine/internal/submodule"
|
"submarine/internal/submodule"
|
||||||
|
"submarine/internal/tui/branches"
|
||||||
"submarine/internal/tui/detail"
|
"submarine/internal/tui/detail"
|
||||||
"submarine/internal/tui/footer"
|
"submarine/internal/tui/footer"
|
||||||
"submarine/internal/tui/header"
|
"submarine/internal/tui/header"
|
||||||
@@ -56,8 +57,10 @@ type Model struct {
|
|||||||
// exactly then. The input is focused while typing and blurred after enter.
|
// exactly then. The input is focused while typing and blurred after enter.
|
||||||
filtering bool
|
filtering bool
|
||||||
|
|
||||||
restore *restoreState // the restore in progress, nil if none
|
restore *restoreState // the restore or switch in progress, nil if none
|
||||||
notice string // an error to show above the footer until the next key
|
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
|
||||||
|
|
||||||
width, height int
|
width, height int
|
||||||
showDetail bool
|
showDetail bool
|
||||||
@@ -159,7 +162,7 @@ func (m *Model) startFetch() tea.Cmd {
|
|||||||
|
|
||||||
// busy reports whether something shows a spinner.
|
// busy reports whether something shows a spinner.
|
||||||
func (m Model) busy() bool {
|
func (m Model) busy() bool {
|
||||||
if m.repo == nil || m.fetch.Running || (m.restore != nil && (m.restore.phase == restoreChecking || m.restore.phase == restoreRunning)) {
|
if m.repo == nil || m.fetch.Running || (m.picker != nil && m.picker.Loading()) || (m.restore != nil && (m.restore.phase == restoreChecking || m.restore.phase == restoreRunning)) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
|
loading := m.repo.Root != nil && m.repo.Root.Status == nil && m.repo.Root.Err == nil
|
||||||
@@ -205,6 +208,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
case changeMsg:
|
case changeMsg:
|
||||||
return m.changed(msg)
|
return m.changed(msg)
|
||||||
|
|
||||||
|
case branchesMsg:
|
||||||
|
return m.branchesLoaded(msg)
|
||||||
|
|
||||||
case lossMsg:
|
case lossMsg:
|
||||||
return m.lossChecked(msg)
|
return m.lossChecked(msg)
|
||||||
|
|
||||||
@@ -237,6 +243,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
return m, cmd
|
return m, cmd
|
||||||
|
|
||||||
case tea.MouseMsg:
|
case tea.MouseMsg:
|
||||||
|
if m.picker != nil {
|
||||||
|
return m.mousePicker(msg)
|
||||||
|
}
|
||||||
m.tree.Update(msg)
|
m.tree.Update(msg)
|
||||||
return m, nil
|
return m, nil
|
||||||
|
|
||||||
@@ -331,6 +340,9 @@ func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) {
|
|||||||
|
|
||||||
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
k := m.keys
|
k := m.keys
|
||||||
|
if m.picker != nil {
|
||||||
|
return m.pickBranch(msg)
|
||||||
|
}
|
||||||
if m.restore != nil {
|
if m.restore != nil {
|
||||||
switch m.restore.phase {
|
switch m.restore.phase {
|
||||||
case restoreChoosing:
|
case restoreChoosing:
|
||||||
@@ -400,6 +412,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
return m, m.filter.Focus()
|
return m, m.filter.Focus()
|
||||||
case key.Matches(msg, k.Restore):
|
case key.Matches(msg, k.Restore):
|
||||||
return m.startRestore()
|
return m.startRestore()
|
||||||
|
case key.Matches(msg, k.Switch):
|
||||||
|
return m.startSwitch()
|
||||||
case key.Matches(msg, k.Issues):
|
case key.Matches(msg, k.Issues):
|
||||||
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
m.tree.SetProblemsOnly(!m.tree.ProblemsOnly())
|
||||||
m.layout()
|
m.layout()
|
||||||
@@ -547,5 +561,11 @@ func (m Model) View() string {
|
|||||||
} else if m.tree.ProblemsOnly() {
|
} else if m.tree.ProblemsOnly() {
|
||||||
parts = append(parts, m.problemsView())
|
parts = append(parts, m.problemsView())
|
||||||
}
|
}
|
||||||
return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, append(parts, m.footer.View())...))
|
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
|
||||||
|
pop := m.picker.View(m.width, m.height)
|
||||||
|
x, y := m.picker.Origin(m.width, m.height)
|
||||||
|
view = overlay(view, pop, x, y, theme.Fg(m.theme.Backdrop))
|
||||||
|
}
|
||||||
|
return view
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,252 @@
|
|||||||
|
// Package branches is the branch picker: a search line above a list of
|
||||||
|
// branches. It holds the state and renders itself; the app decides what the
|
||||||
|
// keys do.
|
||||||
|
package branches
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/bubbles/textinput"
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
"submarine/internal/tui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
const (
|
||||||
|
maxWidth = 72
|
||||||
|
minWidth = 36
|
||||||
|
maxRows = 14
|
||||||
|
frameRows = 6 // border, title, search, rule, hint, border
|
||||||
|
)
|
||||||
|
|
||||||
|
// Model is the picker. The zero value is not usable, use New.
|
||||||
|
type Model struct {
|
||||||
|
theme theme.Theme
|
||||||
|
title string
|
||||||
|
input textinput.Model
|
||||||
|
all []submodule.Branch
|
||||||
|
shown []int // indexes into all that match the search
|
||||||
|
cursor int // index into shown
|
||||||
|
offset int // first visible index into shown
|
||||||
|
rows int // visible list rows
|
||||||
|
loading bool
|
||||||
|
err error
|
||||||
|
spinner string
|
||||||
|
w, h int // size of the popup as last rendered, for hit tests
|
||||||
|
}
|
||||||
|
|
||||||
|
// New returns a picker in the loading state.
|
||||||
|
func New(t theme.Theme, title string) *Model {
|
||||||
|
in := textinput.New()
|
||||||
|
in.Prompt = "/ "
|
||||||
|
in.PromptStyle = theme.Fg(t.Primary)
|
||||||
|
in.Cursor.Style = theme.Fg(t.Primary)
|
||||||
|
in.Placeholder = "filter branches"
|
||||||
|
in.PlaceholderStyle = theme.Fg(t.Muted)
|
||||||
|
in.Focus()
|
||||||
|
return &Model{theme: t, title: title, input: in, loading: true, rows: maxRows}
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetBranches fills the list and ends the loading state.
|
||||||
|
func (m *Model) SetBranches(bs []submodule.Branch) {
|
||||||
|
m.all, m.loading, m.err = bs, false, nil
|
||||||
|
m.refilter()
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetErr shows err instead of the list.
|
||||||
|
func (m *Model) SetErr(err error) { m.loading, m.err = false, err }
|
||||||
|
|
||||||
|
// SetSpinner sets the spinner frame shown while loading.
|
||||||
|
func (m *Model) SetSpinner(s string) { m.spinner = s }
|
||||||
|
|
||||||
|
// Loading reports whether the list is still being read.
|
||||||
|
func (m *Model) Loading() bool { return m.loading }
|
||||||
|
|
||||||
|
// Selected returns the branch under the cursor, nil if none matches.
|
||||||
|
func (m *Model) Selected() *submodule.Branch {
|
||||||
|
if m.cursor < 0 || m.cursor >= len(m.shown) {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
b := m.all[m.shown[m.cursor]]
|
||||||
|
return &b
|
||||||
|
}
|
||||||
|
|
||||||
|
// Title is what the picker switches, e.g. "src/app".
|
||||||
|
func (m *Model) Title() string { return m.title }
|
||||||
|
|
||||||
|
// Search is the text in the search line.
|
||||||
|
func (m *Model) Search() string { return m.input.Value() }
|
||||||
|
|
||||||
|
func (m *Model) Up() { m.move(-1) }
|
||||||
|
func (m *Model) Down() { m.move(1) }
|
||||||
|
func (m *Model) PageUp() { m.move(-max(m.rows-1, 1)) }
|
||||||
|
func (m *Model) PageDown() { m.move(max(m.rows-1, 1)) }
|
||||||
|
|
||||||
|
func (m *Model) move(d int) {
|
||||||
|
m.cursor = max(min(m.cursor+d, len(m.shown)-1), 0)
|
||||||
|
m.scroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
func (m *Model) scroll() {
|
||||||
|
if m.cursor < m.offset {
|
||||||
|
m.offset = m.cursor
|
||||||
|
}
|
||||||
|
if m.cursor >= m.offset+m.rows {
|
||||||
|
m.offset = m.cursor - m.rows + 1
|
||||||
|
}
|
||||||
|
m.offset = max(min(m.offset, len(m.shown)-m.rows), 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update edits the search line. The cursor stays on the same branch if it
|
||||||
|
// still matches.
|
||||||
|
func (m *Model) Update(msg tea.Msg) tea.Cmd {
|
||||||
|
var cmd tea.Cmd
|
||||||
|
before := m.input.Value()
|
||||||
|
m.input, cmd = m.input.Update(msg)
|
||||||
|
if m.input.Value() != before {
|
||||||
|
m.refilter()
|
||||||
|
}
|
||||||
|
return cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
// refilter recomputes shown: every word of the search must be in the name.
|
||||||
|
func (m *Model) refilter() {
|
||||||
|
var keep string
|
||||||
|
if b := m.Selected(); b != nil {
|
||||||
|
keep = b.Ref
|
||||||
|
}
|
||||||
|
words := strings.Fields(strings.ToLower(m.input.Value()))
|
||||||
|
m.shown = m.shown[:0]
|
||||||
|
m.cursor = 0
|
||||||
|
for i, b := range m.all {
|
||||||
|
name := strings.ToLower(b.Name)
|
||||||
|
ok := true
|
||||||
|
for _, w := range words {
|
||||||
|
ok = ok && strings.Contains(name, w)
|
||||||
|
}
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if b.Ref == keep {
|
||||||
|
m.cursor = len(m.shown)
|
||||||
|
}
|
||||||
|
m.shown = append(m.shown, i)
|
||||||
|
}
|
||||||
|
m.offset = 0
|
||||||
|
m.scroll()
|
||||||
|
}
|
||||||
|
|
||||||
|
// View renders the popup for a terminal of the given size.
|
||||||
|
func (m *Model) View(termWidth, termHeight int) string {
|
||||||
|
width := max(min(maxWidth, termWidth-4), minWidth)
|
||||||
|
width = min(width, termWidth)
|
||||||
|
inner := max(width-4, 1) // border and padding
|
||||||
|
// the list is as tall as the branches, so filtering doesn't resize it
|
||||||
|
m.rows = max(min(max(len(m.all), 3), maxRows, termHeight-frameRows-2), 1)
|
||||||
|
m.scroll()
|
||||||
|
m.input.Width = max(inner-3, 1)
|
||||||
|
|
||||||
|
muted := theme.Fg(m.theme.Muted)
|
||||||
|
lines := []string{
|
||||||
|
theme.Fg(m.theme.Primary).Bold(true).Render(ansi.Truncate("Switch "+m.title, inner, "…")),
|
||||||
|
m.input.View(),
|
||||||
|
muted.Render(strings.Repeat(m.theme.Glyphs.Rule, inner)),
|
||||||
|
}
|
||||||
|
|
||||||
|
var body []string
|
||||||
|
switch {
|
||||||
|
case m.loading:
|
||||||
|
body = []string{theme.Fg(m.theme.Primary).Render(m.spinner) + " " + muted.Render("reading branches…")}
|
||||||
|
case m.err != nil:
|
||||||
|
body = []string{theme.Fg(m.theme.Danger).Render(ansi.Truncate(m.err.Error(), inner, "…"))}
|
||||||
|
case len(m.shown) == 0 && len(m.all) == 0:
|
||||||
|
body = []string{muted.Render("no branches")}
|
||||||
|
case len(m.shown) == 0:
|
||||||
|
body = []string{muted.Render("no branch matches")}
|
||||||
|
default:
|
||||||
|
end := min(m.offset+m.rows, len(m.shown))
|
||||||
|
for i := m.offset; i < end; i++ {
|
||||||
|
body = append(body, m.row(m.all[m.shown[i]], i == m.cursor, inner))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for len(body) < m.rows {
|
||||||
|
body = append(body, "")
|
||||||
|
}
|
||||||
|
lines = append(lines, body[:m.rows]...)
|
||||||
|
lines = append(lines, muted.Render(ansi.Truncate("↑↓ select · enter switch · esc cancel", inner, "…")))
|
||||||
|
|
||||||
|
out := lipgloss.NewStyle().
|
||||||
|
Border(lipgloss.RoundedBorder()).
|
||||||
|
BorderForeground(m.theme.Border).
|
||||||
|
Padding(0, 1).
|
||||||
|
Render(strings.Join(lines, "\n"))
|
||||||
|
m.w, m.h = lipgloss.Width(out), lipgloss.Height(out)
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// Origin is the top-left cell of the popup, centered in a terminal of the
|
||||||
|
// given size. Valid after View.
|
||||||
|
func (m *Model) Origin(termWidth, termHeight int) (x, y int) {
|
||||||
|
return max((termWidth-m.w)/2, 0), max((termHeight-m.h)/2, 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// listTop is the line of the popup (border, title, search, rule) where the
|
||||||
|
// list starts.
|
||||||
|
const listTop = 4
|
||||||
|
|
||||||
|
// Click handles a left click at cell (x, y) of a terminal of the given size.
|
||||||
|
// inside reports whether the click was within the popup. If it hit a branch,
|
||||||
|
// that branch is selected and hit is true.
|
||||||
|
func (m *Model) Click(x, y, termWidth, termHeight int) (inside, hit bool) {
|
||||||
|
ox, oy := m.Origin(termWidth, termHeight)
|
||||||
|
x, y = x-ox, y-oy
|
||||||
|
if x < 0 || y < 0 || x >= m.w || y >= m.h {
|
||||||
|
return false, false
|
||||||
|
}
|
||||||
|
row := y - listTop
|
||||||
|
if m.loading || m.err != nil || x == 0 || x == m.w-1 || row < 0 || row >= m.rows || m.offset+row >= len(m.shown) {
|
||||||
|
return true, false
|
||||||
|
}
|
||||||
|
m.cursor = m.offset + row
|
||||||
|
return true, true
|
||||||
|
}
|
||||||
|
|
||||||
|
// row renders one branch: the name, right-aligned the short SHA and age.
|
||||||
|
func (m *Model) row(b submodule.Branch, selected bool, width int) string {
|
||||||
|
base := lipgloss.NewStyle()
|
||||||
|
if selected {
|
||||||
|
base = base.Background(m.theme.Selected)
|
||||||
|
}
|
||||||
|
marker := " "
|
||||||
|
if b.Current {
|
||||||
|
marker = "*"
|
||||||
|
}
|
||||||
|
name, prefix := b.Name, ""
|
||||||
|
if b.Remote {
|
||||||
|
if remote, rest, ok := strings.Cut(b.Name, "/"); ok {
|
||||||
|
prefix, name = remote+"/", rest
|
||||||
|
}
|
||||||
|
}
|
||||||
|
right := base.Foreground(m.theme.Muted).Render(short(b.SHA) + " " + theme.Ago(b.When))
|
||||||
|
room := max(width-lipgloss.Width(right)-3, 1)
|
||||||
|
|
||||||
|
nameStyle := base.Foreground(m.theme.Text)
|
||||||
|
if b.Current {
|
||||||
|
nameStyle = nameStyle.Bold(true)
|
||||||
|
}
|
||||||
|
left := base.Foreground(m.theme.Primary).Render(marker) + base.Render(" ") +
|
||||||
|
base.Foreground(m.theme.Muted).Render(prefix) + nameStyle.Render(name)
|
||||||
|
left = ansi.Truncate(left, room, "…")
|
||||||
|
gap := max(width-lipgloss.Width(left)-lipgloss.Width(right), 1)
|
||||||
|
return left + base.Render(strings.Repeat(" ", gap)) + right
|
||||||
|
}
|
||||||
|
|
||||||
|
func short(sha string) string {
|
||||||
|
if len(sha) > 7 {
|
||||||
|
return sha[:7]
|
||||||
|
}
|
||||||
|
return sha
|
||||||
|
}
|
||||||
@@ -0,0 +1,170 @@
|
|||||||
|
package branches
|
||||||
|
|
||||||
|
import (
|
||||||
|
"errors"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
"submarine/internal/tui/theme"
|
||||||
|
)
|
||||||
|
|
||||||
|
func list() []submodule.Branch {
|
||||||
|
return []submodule.Branch{
|
||||||
|
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaaaaa"},
|
||||||
|
{Ref: "refs/heads/dev", Name: "dev", Local: "dev", SHA: "bbbbbbbbbb"},
|
||||||
|
{Ref: "refs/remotes/origin/main", Name: "origin/main", Local: "main", Remote: true, SHA: "aaaaaaaaaa"},
|
||||||
|
{Ref: "refs/remotes/origin/feat/search", Name: "origin/feat/search", Local: "feat/search", Remote: true, SHA: "cccccccccc"},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func typed(m *Model, s string) {
|
||||||
|
for _, r := range s {
|
||||||
|
m.Update(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{r}})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func name(m *Model) string {
|
||||||
|
if b := m.Selected(); b != nil {
|
||||||
|
return b.Name
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectionMovesAndClamps(t *testing.T) {
|
||||||
|
m := New(theme.Default(), "src/app")
|
||||||
|
m.SetBranches(list())
|
||||||
|
if name(m) != "main" {
|
||||||
|
t.Fatalf("first = %q", name(m))
|
||||||
|
}
|
||||||
|
m.Up()
|
||||||
|
if name(m) != "main" {
|
||||||
|
t.Errorf("up at the top = %q", name(m))
|
||||||
|
}
|
||||||
|
m.Down()
|
||||||
|
m.Down()
|
||||||
|
if name(m) != "origin/main" {
|
||||||
|
t.Errorf("down twice = %q", name(m))
|
||||||
|
}
|
||||||
|
m.PageDown()
|
||||||
|
if name(m) != "origin/feat/search" {
|
||||||
|
t.Errorf("page down = %q, want the last", name(m))
|
||||||
|
}
|
||||||
|
m.Down()
|
||||||
|
if name(m) != "origin/feat/search" {
|
||||||
|
t.Errorf("down at the end = %q", name(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSearchFiltersAndAllWordsMustMatch(t *testing.T) {
|
||||||
|
m := New(theme.Default(), "src/app")
|
||||||
|
m.SetBranches(list())
|
||||||
|
typed(m, "ORIGIN")
|
||||||
|
if len(m.shown) != 2 {
|
||||||
|
t.Errorf("origin matches %d, want 2", len(m.shown))
|
||||||
|
}
|
||||||
|
typed(m, " sea")
|
||||||
|
if len(m.shown) != 1 || name(m) != "origin/feat/search" {
|
||||||
|
t.Errorf("shown = %d, selected %q", len(m.shown), name(m))
|
||||||
|
}
|
||||||
|
typed(m, "zzz")
|
||||||
|
if m.Selected() != nil {
|
||||||
|
t.Errorf("no match must select nothing, got %q", name(m))
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.View(100, 30), "no branch matches") {
|
||||||
|
t.Error("view does not say that nothing matches")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSelectionStaysOnTheSameBranchWhenItStillMatches(t *testing.T) {
|
||||||
|
m := New(theme.Default(), "src/app")
|
||||||
|
m.SetBranches(list())
|
||||||
|
m.Down()
|
||||||
|
m.Down() // origin/main
|
||||||
|
typed(m, "main")
|
||||||
|
if name(m) != "origin/main" {
|
||||||
|
t.Errorf("selected %q, want origin/main kept", name(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestViewStates(t *testing.T) {
|
||||||
|
m := New(theme.Default(), "src/app")
|
||||||
|
if v := m.View(100, 30); !strings.Contains(v, "reading branches") || !strings.Contains(v, "Switch src/app") {
|
||||||
|
t.Errorf("loading view:\n%s", v)
|
||||||
|
}
|
||||||
|
m.SetErr(errors.New("not initialized; restore it first"))
|
||||||
|
if v := m.View(100, 30); !strings.Contains(v, "not initialized") {
|
||||||
|
t.Errorf("error view:\n%s", v)
|
||||||
|
}
|
||||||
|
m.SetBranches(nil)
|
||||||
|
if v := m.View(100, 30); !strings.Contains(v, "no branches") {
|
||||||
|
t.Errorf("empty view:\n%s", v)
|
||||||
|
}
|
||||||
|
m.SetBranches(list())
|
||||||
|
v := m.View(100, 30)
|
||||||
|
for _, want := range []string{"main", "origin/", "feat/search", "aaaaaaa", "enter switch"} {
|
||||||
|
if !strings.Contains(v, want) {
|
||||||
|
t.Errorf("view lacks %q:\n%s", want, v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestListScrollsInASmallTerminal(t *testing.T) {
|
||||||
|
var many []submodule.Branch
|
||||||
|
for i := 0; i < 30; i++ {
|
||||||
|
n := string(rune('a'+i%26)) + string(rune('a'+i/26))
|
||||||
|
many = append(many, submodule.Branch{Ref: "refs/heads/" + n, Name: n, Local: n, SHA: "abcdef0123"})
|
||||||
|
}
|
||||||
|
m := New(theme.Default(), "x")
|
||||||
|
m.SetBranches(many)
|
||||||
|
m.View(80, 14) // fewer rows than branches
|
||||||
|
for i := 0; i < 20; i++ {
|
||||||
|
m.Down()
|
||||||
|
}
|
||||||
|
if m.cursor < m.offset || m.cursor >= m.offset+m.rows {
|
||||||
|
t.Errorf("cursor %d outside the window [%d,%d)", m.cursor, m.offset, m.offset+m.rows)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClickSelectsTheBranchUnderTheMouse(t *testing.T) {
|
||||||
|
m := New(theme.Default(), "src/app")
|
||||||
|
m.SetBranches(list())
|
||||||
|
m.View(100, 30)
|
||||||
|
x, y := m.Origin(100, 30)
|
||||||
|
|
||||||
|
if inside, hit := m.Click(x+3, y+listTop+1, 100, 30); !inside || !hit || name(m) != "dev" {
|
||||||
|
t.Errorf("click on the 2nd row: inside=%v hit=%v selected %q", inside, hit, name(m))
|
||||||
|
}
|
||||||
|
if inside, hit := m.Click(x+3, y+listTop+3, 100, 30); !inside || !hit || name(m) != "origin/feat/search" {
|
||||||
|
t.Errorf("click on the 4th row: inside=%v hit=%v selected %q", inside, hit, name(m))
|
||||||
|
}
|
||||||
|
for _, c := range []struct {
|
||||||
|
name string
|
||||||
|
x, y int
|
||||||
|
}{
|
||||||
|
{"title", x + 3, y + 1},
|
||||||
|
{"search line", x + 3, y + 2},
|
||||||
|
{"hint line", x + 3, y + m.h - 2},
|
||||||
|
{"left border", x, y + listTop},
|
||||||
|
} {
|
||||||
|
before := name(m)
|
||||||
|
if inside, hit := m.Click(c.x, c.y, 100, 30); !inside || hit || name(m) != before {
|
||||||
|
t.Errorf("click on the %s: inside=%v hit=%v selected %q", c.name, inside, hit, name(m))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if inside, hit := m.Click(0, 0, 100, 30); inside || hit {
|
||||||
|
t.Errorf("click outside: inside=%v hit=%v", inside, hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestClickBelowTheLastBranchHitsNothing(t *testing.T) {
|
||||||
|
m := New(theme.Default(), "x")
|
||||||
|
m.SetBranches(list()[:1]) // 1 branch, the list is padded to 3 rows
|
||||||
|
m.View(100, 30)
|
||||||
|
x, y := m.Origin(100, 30)
|
||||||
|
if inside, hit := m.Click(x+3, y+listTop+2, 100, 30); !inside || hit {
|
||||||
|
t.Errorf("inside=%v hit=%v on an empty row", inside, hit)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -7,7 +7,8 @@ type keyMap struct {
|
|||||||
CollapseAll, ExpandAll key.Binding
|
CollapseAll, ExpandAll key.Binding
|
||||||
Home, End, PageUp, PageDown key.Binding
|
Home, End, PageUp, PageDown key.Binding
|
||||||
Fetch, Reload, Filter, Issues key.Binding
|
Fetch, Reload, Filter, Issues key.Binding
|
||||||
Copy, Restore, Help, Quit key.Binding
|
Copy, Restore, Switch key.Binding
|
||||||
|
Help, Quit key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultKeys() keyMap {
|
func defaultKeys() keyMap {
|
||||||
@@ -28,6 +29,7 @@ func defaultKeys() keyMap {
|
|||||||
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")),
|
||||||
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")),
|
||||||
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")),
|
||||||
|
Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")),
|
||||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
||||||
@@ -44,7 +46,7 @@ func (k keyMap) Navigation() []key.Binding {
|
|||||||
|
|
||||||
// Submodule returns the bindings that act on the selected submodule. They
|
// 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.
|
// 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.Restore} }
|
func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Switch, k.Restore} }
|
||||||
|
|
||||||
// Global returns the bindings that don't depend on the selection.
|
// Global returns the bindings that don't depend on the selection.
|
||||||
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
||||||
@@ -58,7 +60,7 @@ func (k keyMap) FullHelp() [][]key.Binding {
|
|||||||
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
{k.Up, k.Down, k.Left, k.Right, k.Toggle},
|
||||||
{k.CollapseAll, k.ExpandAll},
|
{k.CollapseAll, k.ExpandAll},
|
||||||
{k.Home, k.End, k.PageUp, k.PageDown},
|
{k.Home, k.End, k.PageUp, k.PageDown},
|
||||||
{k.Copy, k.Restore},
|
{k.Copy, k.Switch, k.Restore},
|
||||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
||||||
{k.Help, k.Quit},
|
{k.Help, k.Quit},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
// overlay draws fg over bg with its top-left cell at (x, y). bg must be the
|
||||||
|
// final string (after zone.Scan): its lines are cut at the popup's edges.
|
||||||
|
// The backdrop loses its colors and is drawn in dim, so the popup stands out.
|
||||||
|
func overlay(bg, fg string, x, y int, dim lipgloss.Style) string {
|
||||||
|
lines := strings.Split(bg, "\n")
|
||||||
|
for i, l := range lines {
|
||||||
|
lines[i] = dim.Render(ansi.Strip(l))
|
||||||
|
}
|
||||||
|
pop := strings.Split(fg, "\n")
|
||||||
|
pw := lipgloss.Width(fg)
|
||||||
|
left := x
|
||||||
|
for i, p := range pop {
|
||||||
|
row := y + i
|
||||||
|
if row >= len(lines) {
|
||||||
|
break
|
||||||
|
}
|
||||||
|
line := lines[row]
|
||||||
|
if lw := ansi.StringWidth(line); lw < left {
|
||||||
|
line += strings.Repeat(" ", left-lw)
|
||||||
|
}
|
||||||
|
lines[row] = ansi.Truncate(line, left, "") + "\x1b[0m" + p + "\x1b[0m" + ansi.TruncateLeft(line, left+pw, "")
|
||||||
|
}
|
||||||
|
return strings.Join(lines, "\n")
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/lipgloss"
|
||||||
|
|
||||||
|
"github.com/charmbracelet/x/ansi"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestOverlayCentersThePopup(t *testing.T) {
|
||||||
|
bg := strings.Repeat(strings.Repeat(".", 10)+"\n", 4) + strings.Repeat(".", 10)
|
||||||
|
got := strings.Split(ansi.Strip(overlay(bg, "ab\ncd", 4, 1, lipgloss.NewStyle())), "\n")
|
||||||
|
want := []string{"..........", "....ab....", "....cd....", "..........", ".........."}
|
||||||
|
if len(got) != len(want) {
|
||||||
|
t.Fatalf("got %d lines", len(got))
|
||||||
|
}
|
||||||
|
for i := range want {
|
||||||
|
if got[i] != want[i] {
|
||||||
|
t.Errorf("line %d = %q, want %q", i, got[i], want[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestOverlayDimsTheBackdrop(t *testing.T) {
|
||||||
|
bg := "\x1b[31mredredred\x1b[0m"
|
||||||
|
dim := lipgloss.NewStyle().Foreground(lipgloss.Color("8"))
|
||||||
|
got := overlay(bg, "X", 4, 0, dim)
|
||||||
|
if ansi.StringWidth(got) != 9 || ansi.Strip(got) != "redrXdred" {
|
||||||
|
t.Errorf("got %q", ansi.Strip(got))
|
||||||
|
}
|
||||||
|
if strings.Contains(got, "\x1b[31m") {
|
||||||
|
t.Errorf("the backdrop keeps its colors: %q", got)
|
||||||
|
}
|
||||||
|
}
|
||||||
+65
-20
@@ -27,6 +27,7 @@ type restoreState struct {
|
|||||||
phase restorePhase
|
phase restorePhase
|
||||||
to submodule.RestoreTarget
|
to submodule.RestoreTarget
|
||||||
loss submodule.Loss
|
loss submodule.Loss
|
||||||
|
branch *submodule.Branch // set for a branch switch, which reuses this flow
|
||||||
}
|
}
|
||||||
|
|
||||||
// lossMsg is the result of the check before a restore.
|
// lossMsg is the result of the check before a restore.
|
||||||
@@ -37,8 +38,9 @@ type lossMsg struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type restoreDoneMsg struct {
|
type restoreDoneMsg struct {
|
||||||
path string
|
path string
|
||||||
err error
|
branch string // the branch a switch ended on, "" if detached
|
||||||
|
err error
|
||||||
}
|
}
|
||||||
|
|
||||||
// startRestore checks what restoring the selection would discard. If nothing
|
// startRestore checks what restoring the selection would discard. If nothing
|
||||||
@@ -75,10 +77,15 @@ func (m Model) startRestore() (tea.Model, tea.Cmd) {
|
|||||||
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
|
func (m Model) checkRestore() (tea.Model, tea.Cmd) {
|
||||||
m.restore.phase = restoreChecking
|
m.restore.phase = restoreChecking
|
||||||
m.layout()
|
m.layout()
|
||||||
r := m.provider.(submodule.Restorer)
|
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
|
||||||
ctx, path, to := m.ctx, m.restore.path, m.restore.to
|
|
||||||
check := func() tea.Msg {
|
check := func() tea.Msg {
|
||||||
loss, err := r.RestoreLoss(ctx, path, to)
|
var loss submodule.Loss
|
||||||
|
var err error
|
||||||
|
if b != nil {
|
||||||
|
loss, err = m.provider.(submodule.Switcher).SwitchLoss(ctx, path, *b)
|
||||||
|
} else {
|
||||||
|
loss, err = m.provider.(submodule.Restorer).RestoreLoss(ctx, path, to)
|
||||||
|
}
|
||||||
return lossMsg{path, loss, err}
|
return lossMsg{path, loss, err}
|
||||||
}
|
}
|
||||||
return m, tea.Batch(check, m.tick())
|
return m, tea.Batch(check, m.tick())
|
||||||
@@ -139,40 +146,78 @@ func (m Model) answerRestore(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
func (m Model) runRestore() (tea.Model, tea.Cmd) {
|
func (m Model) runRestore() (tea.Model, tea.Cmd) {
|
||||||
m.restore.phase = restoreRunning
|
m.restore.phase = restoreRunning
|
||||||
m.layout()
|
m.layout()
|
||||||
r := m.provider.(submodule.Restorer)
|
ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch
|
||||||
ctx, path, to := m.ctx, m.restore.path, m.restore.to
|
run := func() tea.Msg {
|
||||||
run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path, to)} }
|
if b != nil {
|
||||||
|
branch, err := m.provider.(submodule.Switcher).Switch(ctx, path, *b)
|
||||||
|
return restoreDoneMsg{path, branch, err}
|
||||||
|
}
|
||||||
|
return restoreDoneMsg{path, "", m.provider.(submodule.Restorer).Restore(ctx, path, to)}
|
||||||
|
}
|
||||||
return m, tea.Batch(run, m.tick())
|
return m, tea.Batch(run, m.tick())
|
||||||
}
|
}
|
||||||
|
|
||||||
// restoreDone shows the result and reloads everything: the structure may have
|
// restoreDone shows the result and reloads everything: the structure may have
|
||||||
// changed.
|
// changed.
|
||||||
func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) {
|
||||||
name := "the superproject"
|
r := m.restore
|
||||||
if m.restore != nil {
|
|
||||||
name = m.restore.name + m.restore.toText()
|
|
||||||
}
|
|
||||||
m.restore = nil
|
m.restore = nil
|
||||||
var cmds []tea.Cmd
|
var cmds []tea.Cmd
|
||||||
if msg.err != nil {
|
switch {
|
||||||
m.notice = "restore of " + name + " failed: " + msg.err.Error()
|
case r != nil && r.branch != nil && msg.err != nil:
|
||||||
} else {
|
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
|
||||||
cmds = append(cmds, m.footer.Flash("restored "+name))
|
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)"
|
||||||
|
}
|
||||||
|
cmds = append(cmds, m.footer.Flash("switched "+r.name+" to "+to))
|
||||||
|
default:
|
||||||
|
name := "the superproject"
|
||||||
|
if r != nil {
|
||||||
|
name = r.name + r.toText()
|
||||||
|
}
|
||||||
|
if msg.err != nil {
|
||||||
|
m.notice = "restore of " + name + " failed: " + msg.err.Error()
|
||||||
|
} else {
|
||||||
|
cmds = append(cmds, m.footer.Flash("restored "+name))
|
||||||
|
}
|
||||||
}
|
}
|
||||||
cmds = append(cmds, m.reloadTree(true))
|
cmds = append(cmds, m.reloadTree(true))
|
||||||
m.layout()
|
m.layout()
|
||||||
return m, tea.Batch(cmds...)
|
return m, tea.Batch(cmds...)
|
||||||
}
|
}
|
||||||
|
|
||||||
// toText is " to the committed pin" if that was chosen; the staged pin is the
|
// 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.
|
// usual target and needs no words.
|
||||||
func (r *restoreState) toText() string {
|
func (r *restoreState) toText() string {
|
||||||
|
if r.branch != nil {
|
||||||
|
return " to " + r.branch.Name
|
||||||
|
}
|
||||||
if r.to == submodule.RestoreCommitted {
|
if r.to == submodule.RestoreCommitted {
|
||||||
return " to the committed pin"
|
return " to the committed pin"
|
||||||
}
|
}
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// verb and verbIng word the operation: a switch is shown like a restore, but
|
||||||
|
// names its branch.
|
||||||
|
func (r *restoreState) verb() string {
|
||||||
|
if r.branch != nil {
|
||||||
|
return "Switch"
|
||||||
|
}
|
||||||
|
return "Restore"
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *restoreState) verbIng() string {
|
||||||
|
if r.branch != nil {
|
||||||
|
return "switching"
|
||||||
|
}
|
||||||
|
return "restoring"
|
||||||
|
}
|
||||||
|
|
||||||
func restoreName(s *submodule.Submodule) string {
|
func restoreName(s *submodule.Submodule) string {
|
||||||
if s == nil || s.IsRoot() {
|
if s == nil || s.IsRoot() {
|
||||||
return "the superproject"
|
return "the superproject"
|
||||||
@@ -194,11 +239,11 @@ func (m Model) restoreView() string {
|
|||||||
case restoreChecking:
|
case restoreChecking:
|
||||||
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Muted).Render("checking "+r.name+"…")
|
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Muted).Render("checking "+r.name+"…")
|
||||||
case restoreRunning:
|
case restoreRunning:
|
||||||
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render("restoring "+r.name+r.toText()+"…")
|
left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Warn).Render(r.verbIng()+" "+r.name+r.toText()+"…")
|
||||||
case restoreAsking:
|
case restoreAsking:
|
||||||
left = theme.Fg(m.theme.Warn).Render("Restore "+r.name+r.toText()+"?") + " " +
|
left = theme.Fg(m.theme.Warn).Render(r.verb()+" "+r.name+r.toText()+"?") + " " +
|
||||||
theme.Fg(m.theme.Danger).Render("Discards "+lossText(r.loss)+".")
|
theme.Fg(m.theme.Danger).Render("Discards "+lossText(r.loss)+".")
|
||||||
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" restore · ") +
|
right = theme.Fg(m.theme.Text).Bold(true).Render("y") + theme.Fg(m.theme.Muted).Render(" "+strings.ToLower(r.verb())+" · ") +
|
||||||
theme.Fg(m.theme.Text).Bold(true).Render("n") + theme.Fg(m.theme.Muted).Render(" cancel")
|
theme.Fg(m.theme.Text).Bold(true).Render("n") + theme.Fg(m.theme.Muted).Render(" cancel")
|
||||||
}
|
}
|
||||||
return m.line(left, right)
|
return m.line(left, right)
|
||||||
|
|||||||
@@ -92,7 +92,7 @@ func newRestoreModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeRestore
|
|||||||
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
|
func feed(m tea.Model, cmd tea.Cmd) tea.Model {
|
||||||
for _, msg := range run(cmd) {
|
for _, msg := range run(cmd) {
|
||||||
switch msg.(type) {
|
switch msg.(type) {
|
||||||
case lossMsg, restoreDoneMsg:
|
case lossMsg, restoreDoneMsg, branchesMsg:
|
||||||
m, _ = m.Update(msg)
|
m, _ = m.Update(msg)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,125 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
"submarine/internal/tui/branches"
|
||||||
|
)
|
||||||
|
|
||||||
|
// branchesMsg is the branch list for the picker of path.
|
||||||
|
type branchesMsg struct {
|
||||||
|
path string
|
||||||
|
branches []submodule.Branch
|
||||||
|
err error
|
||||||
|
}
|
||||||
|
|
||||||
|
// startSwitch opens the branch picker for the selection and reads its
|
||||||
|
// branches.
|
||||||
|
func (m Model) startSwitch() (tea.Model, tea.Cmd) {
|
||||||
|
s := m.tree.Selected()
|
||||||
|
sw, ok := m.provider.(submodule.Switcher)
|
||||||
|
var flash string
|
||||||
|
switch {
|
||||||
|
case s == nil:
|
||||||
|
return m, nil
|
||||||
|
case !ok:
|
||||||
|
flash = "this data source can't switch branches"
|
||||||
|
case m.restore != nil:
|
||||||
|
flash = "wait for the restore of " + m.restore.name + " to finish"
|
||||||
|
case !s.IsRoot() && s.Status != nil && s.Status.Presence != submodule.PresenceOK:
|
||||||
|
flash = s.Path + " is not initialized; restore it first"
|
||||||
|
}
|
||||||
|
if flash != "" {
|
||||||
|
cmd := m.footer.Flash(flash)
|
||||||
|
m.layout()
|
||||||
|
return m, cmd
|
||||||
|
}
|
||||||
|
|
||||||
|
m.picker, m.pickerPath = branches.New(m.theme, restoreName(s)), s.Path
|
||||||
|
m.picker.SetSpinner(m.spinner.View())
|
||||||
|
ctx, path := m.ctx, s.Path
|
||||||
|
load := func() tea.Msg {
|
||||||
|
bs, err := sw.Branches(ctx, path)
|
||||||
|
return branchesMsg{path, bs, err}
|
||||||
|
}
|
||||||
|
return m, tea.Batch(load, m.tick())
|
||||||
|
}
|
||||||
|
|
||||||
|
// branchesLoaded fills the picker, unless it was closed or is for another
|
||||||
|
// repository meanwhile.
|
||||||
|
func (m Model) branchesLoaded(msg branchesMsg) (tea.Model, tea.Cmd) {
|
||||||
|
if m.picker == nil || m.pickerPath != msg.path {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
if msg.err != nil {
|
||||||
|
m.picker.SetErr(msg.err)
|
||||||
|
} else {
|
||||||
|
m.picker.SetBranches(msg.branches)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// pickBranch handles a key while the picker is open: arrows move, enter
|
||||||
|
// switches, esc closes, everything else edits the search.
|
||||||
|
func (m Model) pickBranch(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.Type {
|
||||||
|
case tea.KeyCtrlC:
|
||||||
|
return m, tea.Quit
|
||||||
|
case tea.KeyEsc:
|
||||||
|
m.picker = nil
|
||||||
|
case tea.KeyUp:
|
||||||
|
m.picker.Up()
|
||||||
|
case tea.KeyDown:
|
||||||
|
m.picker.Down()
|
||||||
|
case tea.KeyPgUp:
|
||||||
|
m.picker.PageUp()
|
||||||
|
case tea.KeyPgDown:
|
||||||
|
m.picker.PageDown()
|
||||||
|
case tea.KeyEnter:
|
||||||
|
return m.confirmPick()
|
||||||
|
default:
|
||||||
|
return m, m.picker.Update(msg)
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// confirmPick closes the picker and switches to the selected branch, with
|
||||||
|
// the same check and question as a restore.
|
||||||
|
func (m Model) confirmPick() (tea.Model, tea.Cmd) {
|
||||||
|
b := m.picker.Selected()
|
||||||
|
if b == nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
s := m.byPath[m.pickerPath]
|
||||||
|
m.picker = nil
|
||||||
|
if s == nil || m.restore != nil {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
m.restore = &restoreState{path: s.Path, name: restoreName(s), branch: b}
|
||||||
|
return m.checkRestore()
|
||||||
|
}
|
||||||
|
|
||||||
|
// mousePicker handles the mouse while the picker is open: the wheel moves
|
||||||
|
// the selection, a click on a branch switches to it, a click outside the
|
||||||
|
// popup closes it.
|
||||||
|
func (m Model) mousePicker(msg tea.MouseMsg) (tea.Model, tea.Cmd) {
|
||||||
|
switch msg.Button {
|
||||||
|
case tea.MouseButtonWheelUp:
|
||||||
|
m.picker.Up()
|
||||||
|
case tea.MouseButtonWheelDown:
|
||||||
|
m.picker.Down()
|
||||||
|
case tea.MouseButtonLeft:
|
||||||
|
if msg.Action != tea.MouseActionPress {
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
inside, hit := m.picker.Click(msg.X, msg.Y, m.width, m.height)
|
||||||
|
switch {
|
||||||
|
case hit:
|
||||||
|
return m.confirmPick()
|
||||||
|
case !inside:
|
||||||
|
m.picker = nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
@@ -0,0 +1,224 @@
|
|||||||
|
package tui
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
|
|
||||||
|
"submarine/internal/submodule"
|
||||||
|
"submarine/internal/submodule/mock"
|
||||||
|
)
|
||||||
|
|
||||||
|
// fakeSwitcher is the mock data with a branch switch that records its calls.
|
||||||
|
type fakeSwitcher struct {
|
||||||
|
submodule.Provider
|
||||||
|
loss submodule.Loss
|
||||||
|
branches []submodule.Branch
|
||||||
|
switched []string // path + " " + ref
|
||||||
|
ends string // the branch Switch reports
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSwitcher) Branches(context.Context, string) ([]submodule.Branch, error) {
|
||||||
|
return f.branches, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSwitcher) SwitchLoss(context.Context, string, submodule.Branch) (submodule.Loss, error) {
|
||||||
|
return f.loss, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (f *fakeSwitcher) Switch(_ context.Context, path string, b submodule.Branch) (string, error) {
|
||||||
|
f.switched = append(f.switched, path+" "+b.Ref)
|
||||||
|
return f.ends, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func newSwitchModel(t *testing.T, loss submodule.Loss) (tea.Model, *fakeSwitcher) {
|
||||||
|
t.Helper()
|
||||||
|
f := &fakeSwitcher{
|
||||||
|
Provider: mock.New(),
|
||||||
|
loss: loss,
|
||||||
|
ends: "feat",
|
||||||
|
branches: []submodule.Branch{
|
||||||
|
{Ref: "refs/heads/main", Name: "main", Local: "main", Current: true, SHA: "aaaaaaa"},
|
||||||
|
{Ref: "refs/remotes/origin/feat", Name: "origin/feat", Local: "feat", Remote: true, SHA: "bbbbbbb"},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
var m tea.Model = New(context.Background(), f)
|
||||||
|
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||||||
|
repo, err := f.Tree(context.Background())
|
||||||
|
if err != nil {
|
||||||
|
t.Fatal(err)
|
||||||
|
}
|
||||||
|
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
||||||
|
return m, f
|
||||||
|
}
|
||||||
|
|
||||||
|
// openPicker presses b and delivers the branch list.
|
||||||
|
func openPicker(t *testing.T, m tea.Model) tea.Model {
|
||||||
|
t.Helper()
|
||||||
|
m, cmd := press(m, "b")
|
||||||
|
m = feed(m, cmd)
|
||||||
|
if m.(Model).picker == nil {
|
||||||
|
t.Fatal("the picker did not open")
|
||||||
|
}
|
||||||
|
return m
|
||||||
|
}
|
||||||
|
|
||||||
|
func pressKey(m tea.Model, t tea.KeyType) (tea.Model, tea.Cmd) {
|
||||||
|
return m.Update(tea.KeyMsg{Type: t})
|
||||||
|
}
|
||||||
|
|
||||||
|
// settle delivers the loss check and, if it ran at once, the switch.
|
||||||
|
func settle(m tea.Model, cmd tea.Cmd) tea.Model {
|
||||||
|
var next tea.Cmd
|
||||||
|
for _, msg := range run(cmd) {
|
||||||
|
if l, ok := msg.(lossMsg); ok {
|
||||||
|
m, next = m.Update(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return feed(m, next)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchOpensThePickerWithTheBranches(t *testing.T) {
|
||||||
|
m, _ := newSwitchModel(t, submodule.Loss{})
|
||||||
|
m = openPicker(t, m)
|
||||||
|
v := m.View()
|
||||||
|
for _, want := range []string{"Switch the superproject", "main", "origin/", "feat"} {
|
||||||
|
if !strings.Contains(v, want) {
|
||||||
|
t.Errorf("view lacks %q", want)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchTypingFiltersAndEnterSwitchesWithoutLoss(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{})
|
||||||
|
m = openPicker(t, m)
|
||||||
|
m, _ = press(m, "fe") // j and k would be text, not navigation
|
||||||
|
m, cmd := pressKey(m, tea.KeyEnter)
|
||||||
|
if m.(Model).picker != nil {
|
||||||
|
t.Error("enter must close the picker")
|
||||||
|
}
|
||||||
|
m = settle(m, cmd)
|
||||||
|
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||||
|
t.Fatalf("switched %q", f.switched)
|
||||||
|
}
|
||||||
|
if m.(Model).restore != nil {
|
||||||
|
t.Error("switch still in progress")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchArrowsSelect(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{})
|
||||||
|
m = openPicker(t, m)
|
||||||
|
m, _ = pressKey(m, tea.KeyDown)
|
||||||
|
m, cmd := pressKey(m, tea.KeyEnter)
|
||||||
|
settle(m, cmd)
|
||||||
|
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||||
|
t.Fatalf("switched %q, want the second branch", f.switched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchAsksWhenSomethingIsLost(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{Files: 2, Modules: 1})
|
||||||
|
m = openPicker(t, m)
|
||||||
|
m, cmd := pressKey(m, tea.KeyEnter)
|
||||||
|
var next tea.Cmd
|
||||||
|
for _, msg := range run(cmd) {
|
||||||
|
if l, ok := msg.(lossMsg); ok {
|
||||||
|
m, next = m.Update(l)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
r := m.(Model).restore
|
||||||
|
if next != nil || r == nil || r.phase != restoreAsking {
|
||||||
|
t.Fatalf("want a question, got %+v", r)
|
||||||
|
}
|
||||||
|
if v := m.View(); !strings.Contains(v, "Switch the superproject to main?") || !strings.Contains(v, "y switch") {
|
||||||
|
t.Errorf("question not shown:\n%s", v)
|
||||||
|
}
|
||||||
|
if c, _ := press(m, "n"); c.(Model).restore != nil || len(f.switched) != 0 {
|
||||||
|
t.Fatal("n must cancel")
|
||||||
|
}
|
||||||
|
m, cmd = press(m, "y")
|
||||||
|
feed(m, cmd)
|
||||||
|
if len(f.switched) != 1 {
|
||||||
|
t.Fatalf("switched %q", f.switched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchEscClosesThePicker(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{})
|
||||||
|
m = openPicker(t, m)
|
||||||
|
m, _ = pressKey(m, tea.KeyEsc)
|
||||||
|
if m.(Model).picker != nil || len(f.switched) != 0 {
|
||||||
|
t.Fatal("esc must close without switching")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchWithoutSwitcherSaysSo(t *testing.T) {
|
||||||
|
var m tea.Model = New(context.Background(), mock.New())
|
||||||
|
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
|
||||||
|
repo, _ := mock.New().Tree(context.Background())
|
||||||
|
m, _ = m.Update(treeLoadedMsg{repo: repo})
|
||||||
|
m, _ = press(m, "b")
|
||||||
|
if m.(Model).picker != nil {
|
||||||
|
t.Fatal("the mock can't switch")
|
||||||
|
}
|
||||||
|
if !strings.Contains(m.View(), "can't switch branches") {
|
||||||
|
t.Error("no flash")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchReportsADetachedHead(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{})
|
||||||
|
f.ends = ""
|
||||||
|
m = openPicker(t, m)
|
||||||
|
m, _ = pressKey(m, tea.KeyDown)
|
||||||
|
m, cmd := pressKey(m, tea.KeyEnter)
|
||||||
|
m = settle(m, cmd)
|
||||||
|
if v := m.View(); !strings.Contains(v, "detached") {
|
||||||
|
t.Errorf("flash lacks the detached note:\n%s", v)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// clickRow clicks the i-th visible branch of the open picker.
|
||||||
|
func clickRow(m tea.Model, i int) (tea.Model, tea.Cmd) {
|
||||||
|
mm := m.(Model)
|
||||||
|
x, y := mm.picker.Origin(mm.width, mm.height)
|
||||||
|
return m.Update(tea.MouseMsg{X: x + 3, Y: y + 4 + i, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchClickOnABranchSwitches(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{})
|
||||||
|
m = openPicker(t, m)
|
||||||
|
m.View() // the popup learns where it is drawn
|
||||||
|
m, cmd := clickRow(m, 1)
|
||||||
|
if m.(Model).picker != nil {
|
||||||
|
t.Error("the click must close the picker")
|
||||||
|
}
|
||||||
|
settle(m, cmd)
|
||||||
|
if len(f.switched) != 1 || f.switched[0] != " refs/remotes/origin/feat" {
|
||||||
|
t.Fatalf("switched %q, want the clicked branch", f.switched)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestSwitchClickOutsideClosesAndWheelMoves(t *testing.T) {
|
||||||
|
m, f := newSwitchModel(t, submodule.Loss{})
|
||||||
|
m = openPicker(t, m)
|
||||||
|
m.View()
|
||||||
|
m, _ = m.Update(tea.MouseMsg{Button: tea.MouseButtonWheelDown, Action: tea.MouseActionPress})
|
||||||
|
if b := m.(Model).picker.Selected(); b == nil || b.Name != "origin/feat" {
|
||||||
|
t.Errorf("wheel down selected %v", b)
|
||||||
|
}
|
||||||
|
// a click that releases or sits inside the popup's frame does nothing
|
||||||
|
mm := m.(Model)
|
||||||
|
x, y := mm.picker.Origin(mm.width, mm.height)
|
||||||
|
m, _ = m.Update(tea.MouseMsg{X: x + 3, Y: y + 1, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
|
||||||
|
if m.(Model).picker == nil {
|
||||||
|
t.Fatal("a click on the title must keep the popup open")
|
||||||
|
}
|
||||||
|
m, _ = m.Update(tea.MouseMsg{X: 0, Y: 0, Button: tea.MouseButtonLeft, Action: tea.MouseActionPress})
|
||||||
|
if m.(Model).picker != nil || len(f.switched) != 0 {
|
||||||
|
t.Fatal("a click outside must close without switching")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,8 +8,9 @@ import (
|
|||||||
)
|
)
|
||||||
|
|
||||||
// Theme uses the 16 ANSI colors for everything that carries meaning, so
|
// Theme uses the 16 ANSI colors for everything that carries meaning, so
|
||||||
// the TUI follows the user's terminal color scheme. Only background shades,
|
// the TUI follows the user's terminal color scheme. Only background shades
|
||||||
// which have no ANSI equivalent, use adaptive 256-color values.
|
// and the dimmed backdrop, which have no ANSI equivalent, use adaptive
|
||||||
|
// 256-color values.
|
||||||
type Theme struct {
|
type Theme struct {
|
||||||
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
|
Primary lipgloss.TerminalColor // accent: branch names, commit hashes
|
||||||
Info lipgloss.TerminalColor
|
Info lipgloss.TerminalColor
|
||||||
@@ -21,6 +22,7 @@ type Theme struct {
|
|||||||
Border lipgloss.TerminalColor // pane separators, help separators
|
Border lipgloss.TerminalColor // pane separators, help separators
|
||||||
Bar lipgloss.TerminalColor // background of the header bar
|
Bar lipgloss.TerminalColor // background of the header bar
|
||||||
Selected lipgloss.TerminalColor // background of the selected row
|
Selected lipgloss.TerminalColor // background of the selected row
|
||||||
|
Backdrop lipgloss.TerminalColor // the screen behind a popup, dimmed
|
||||||
Glyphs Glyphs
|
Glyphs Glyphs
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -40,6 +42,7 @@ func Default() Theme {
|
|||||||
Border: lipgloss.Color("8"),
|
Border: lipgloss.Color("8"),
|
||||||
Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"},
|
Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"},
|
||||||
Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"},
|
Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"},
|
||||||
|
Backdrop: lipgloss.AdaptiveColor{Light: "252", Dark: "238"},
|
||||||
Glyphs: g,
|
Glyphs: g,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user