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>
This commit is contained in:
2026-10-22 14:18:49 +02:00
co-authored by Claude Opus 5.5
parent b9f6055759
commit 2b6b781826
21 changed files with 1311 additions and 43 deletions
+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.
+80 -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,72 @@ 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 and all other keys are 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 +321,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 +340,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).
+14 -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 centered over the finished view
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
+35
View File
@@ -349,3 +349,38 @@ 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. 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
+30
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 |
+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
+22 -4
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,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, nil
}
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,9 @@ 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
view = overlay(view, m.picker.View(m.width, m.height), m.width, m.height, theme.Fg(m.theme.Backdrop))
}
return view
} }
+222
View File
@@ -0,0 +1,222 @@
// 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
}
// 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, "…")))
return lipgloss.NewStyle().
Border(lipgloss.RoundedBorder()).
BorderForeground(m.theme.Border).
Padding(0, 1).
Render(strings.Join(lines, "\n"))
}
// 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
}
+129
View File
@@ -0,0 +1,129 @@
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)
}
}
+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},
} }
+34
View File
@@ -0,0 +1,34 @@
package tui
import (
"strings"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
)
// overlay draws fg centered over bg, which is w x h cells. 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, w, h 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)
top := max((h-len(pop))/2, 0)
left := max((w-pw)/2, 0)
for i, p := range pop {
y := top + i
if y >= len(lines) {
break
}
line := lines[y]
if lw := ansi.StringWidth(line); lw < left {
line += strings.Repeat(" ", left-lw)
}
lines[y] = 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", 10, 5, 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", 9, 1, 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
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.
@@ -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)
+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)
} }
} }
+95
View File
@@ -0,0 +1,95 @@
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:
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()
default:
return m, m.picker.Update(msg)
}
return m, nil
}
+182
View File
@@ -0,0 +1,182 @@
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)
}
}
+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,
} }
} }