3 Commits
Author SHA1 Message Date
simonfrankenandClaude Sonnet 5.5 01a9babe64 Add a release skill
Picks the next semver from the commits since the last tag, runs a release
check, tags and pushes, hands over to the developer for make release, then
creates the Gitea release with MCP.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:38:14 +02:00
simonfrankenandClaude Sonnet 5.5 648e1fd46c Support the mouse in the branch popup
A click on a branch switches to it, the wheel moves the selection, a click
outside closes the popup. The popup is hit-tested by its own position, as it
is drawn after zone.Scan.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
2026-10-22 14:38:14 +02:00
simonfrankenandClaude Opus 5.5 2b6b781826 Add switch branch (b): stage the tip and restore to it
b opens a centered popup with a search line and the local and remote
branches of the selection. Enter stages the branch tip as the pin of a
submodule, restores it (and everything below) to it, and puts HEAD on the
branch; a remote branch gets a local tracking branch unless one exists
elsewhere. Works on the superproject too. Losses are asked like restore.
The backdrop behind the popup is dimmed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-22 14:18:49 +02:00
23 changed files with 1633 additions and 44 deletions
+158
View File
@@ -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.
+8 -1
View File
@@ -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
+2 -1
View File
@@ -111,6 +111,7 @@ 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 |
@@ -121,7 +122,7 @@ The mouse works too: click to select, click `▸` to toggle, wheel to move.
## Status ## Status
A prototype. Browsing is complete, and `x` restores a submodule or the whole A prototype. Browsing is complete, and `x` restores a submodule or the whole
superproject. More write actions (stage pin) are being specified in superproject, and `b` switches its branch. More write actions (stage pin) are being specified in
[docs/actions.md](docs/actions.md). [docs/actions.md](docs/actions.md).
## Documentation ## Documentation
+4 -4
View File
@@ -14,7 +14,7 @@ bare commit hash.
| [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs | | [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
View File
@@ -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
View File
@@ -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`,
+37
View File
@@ -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).
+8 -1
View File
@@ -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
+33
View File
@@ -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
+132
View File
@@ -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
}
+181
View File
@@ -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)
}
}
+29 -1
View File
@@ -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
+23 -3
View File
@@ -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,7 +57,9 @@ 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
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 notice string // an error to show above the footer until the next key
width, height int width, height int
@@ -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
} }
+252
View File
@@ -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
}
+170
View File
@@ -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)
}
}
+5 -3
View File
@@ -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},
} }
+33
View File
@@ -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")
}
+36
View File
@@ -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)
}
}
+59 -14
View File
@@ -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.
@@ -38,6 +39,7 @@ type lossMsg struct {
type restoreDoneMsg struct { type restoreDoneMsg struct {
path string path string
branch string // the branch a switch ended on, "" if detached
err error err error
} }
@@ -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
switch {
case r != nil && r.branch != nil && msg.err != nil:
m.notice = "switch of " + r.name + " to " + r.branch.Name + " failed: " + msg.err.Error()
case r != nil && r.branch != nil:
to := r.branch.Name
if msg.branch != "" {
to = msg.branch
} else if r.branch.Remote {
to += " (detached: local " + r.branch.Local + " is elsewhere)"
}
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 { if msg.err != nil {
m.notice = "restore of " + name + " failed: " + msg.err.Error() m.notice = "restore of " + name + " failed: " + msg.err.Error()
} else { } else {
cmds = append(cmds, m.footer.Flash("restored "+name)) 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)
+1 -1
View File
@@ -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)
} }
} }
+125
View File
@@ -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
}
+224
View File
@@ -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")
}
}
+5 -2
View File
@@ -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,
} }
} }