diff --git a/README.md b/README.md index 535c78d..e5b9c3c 100644 --- a/README.md +++ b/README.md @@ -111,6 +111,7 @@ files. | `/` | Filter | | `!` | Problems only | | `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) | | `f` / `r` | Fetch / reload | | `?` | Full help | @@ -121,7 +122,7 @@ The mouse works too: click to select, click `▸` to toggle, wheel to move. ## Status 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). ## Documentation diff --git a/docs/README.md b/docs/README.md index c9da0a7..78fb958 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,7 +14,7 @@ bare commit hash. | [state-model.md](state-model.md) | How a submodule's state is determined, summarized into issues, and how pins are resolved to refs | | [ux-guide.md](ux-guide.md) | Layout, attention hierarchy, colors, wording, navigation and key bindings | | [architecture.md](architecture.md) | Package layout, the provider boundary, async loading, testing | -| [actions.md](actions.md) | **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) | ## 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 fetches only when asked (`f`) and follows changes made in other shells. `submarine --mock` shows the built-in mock data (`internal/submodule/mock`). `!` shows only -submodules with problems. `x` restores the selection -([actions.md](actions.md)); stage pin is drafted there. +submodules with problems. `x` restores the selection and `b` switches its +branch ([actions.md](actions.md)); stage pin is drafted there. 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. diff --git a/docs/actions.md b/docs/actions.md index b57a355..a21478e 100644 --- a/docs/actions.md +++ b/docs/actions.md @@ -1,7 +1,7 @@ # Actions (draft) -**Status:** [Restore](#restore) is implemented (key `x`, decisions in -[D23](decisions.md)). [Stage pin](#stage-pin) is still a draft; its open +**Status:** [Restore](#restore) (key `x`, [D23](decisions.md)) and +[Switch branch](#switch-branch) (key `b`, [D24](decisions.md)) are implemented. [Stage pin](#stage-pin) is still a draft; its open questions are [at the end](#open-questions). ## Principle @@ -14,10 +14,11 @@ a difference in one of two directions: |---|---|---| | [Restore](#restore) | The worktree follows the recorded state | `x` | | [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 -bumping a pin to the tip are done outside submarine. A bump is "check out -the tip in the submodule, then `s`". +There is nothing else for now. Committing, pushing and bumping a pin to the +tip are done outside submarine. A bump is "check out the tip in the +submodule, then `s`" (or switch to the branch with `b`). ## 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 `.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 --track `. +`SwitchLoss` is `lossOf` with `T` as the target. `Branches` is one +`for-each-ref`, without the `origin/HEAD` symref. + ## Stage pin 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) | |---|---|---| | `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` | `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. - UI (`internal/tui/restore_test.go`): restores at once without a loss, asks with one (`y` restores, anything else cancels), the mock can't restore. +- Switch: provider (`switch_test.go`): list without `origin/HEAD`, local + first; a remote pick stages the tip and creates a tracking branch; an + existing local branch elsewhere is not moved; a moved ref aborts; the + 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 [architecture.md](architecture.md#testing). diff --git a/docs/architecture.md b/docs/architecture.md index 9616c40..c39fff6 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -12,25 +12,30 @@ cmd/submarine/main.go wiring: choose provider (git or --mock), start the internal/submodule/ domain model; NO UI imports model.go Repo, Submodule, Status, PinInfo, Commit, enums 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) exec.go git runner (environment, errors) parse.go pure parsers for git output (status, numstat, …) provider.go Tree, Status, pin → ref resolution fetch.go Fetch (network), Fetchable watch.go Watch: polls git metadata for changes + restore.go Restore, RestoreLoss (network: only what is missing) + switch.go Branches, SwitchLoss, Switch (stage the tip, restore, attach the branch) gitdir.go resolves .git dirs and gitdir: files internal/submodule/mock/ fixture provider with artificial latency internal/update/ self-update from the package registry; NO UI imports internal/tui/ Bubble Tea application app.go root model: layout, message routing, loading keys.go key bindings (also feed the help view) + restore.go, switch.go restore / switch flows; a switch reuses restoreState + overlay.go draws the picker centered over the finished view msgs.go tree/status/fetch/change messages refresh.go reloader: one load per key, coalesced reruns theme/ colors, glyphs, issue words, change letters, level styles tree/ tree component: rows, navigation, mouse, view detail/ detail pane; changes.go: change preview header/ header line + branches/ branch picker popup (search line + list) footer/ key bar (navigation left, global right) and flash messages ``` @@ -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 type Locator interface { Dir(path string) string // absolute directory, "" if unknown diff --git a/docs/decisions.md b/docs/decisions.md index b061ffb..11b23d2 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -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 failure. It takes the time of its slowest clone; there is no per-repository 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). diff --git a/docs/requirements.md b/docs/requirements.md index 5294c01..3136dfa 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -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 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). ## 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 provider are interchangeable without UI changes. - **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` 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 diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 223bc5c..4e49242 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -183,6 +183,7 @@ colors in components. | Border | ANSI `8` grey | pane and help separators | | Bar | adaptive 254 (light) / 235 (dark) | header 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`. 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 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 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 | | `pgup`, `pgdn` | Move by one screen | Navigation | Help | | `c` | Copy path | Submodule | Palette, Help | +| `b` | Switch branch (popup) | Submodule | Palette, Help | | `x` | Restore | Submodule | Palette, Help | | `f` | Fetch all repositories | Global | Help | | `r` | Reload everything | Global | Help | diff --git a/internal/submodule/git/switch.go b/internal/submodule/git/switch.go new file mode 100644 index 0000000..c863c23 --- /dev/null +++ b/internal/submodule/git/switch.go @@ -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 +} diff --git a/internal/submodule/git/switch_test.go b/internal/submodule/git/switch_test.go new file mode 100644 index 0000000..b81f411 --- /dev/null +++ b/internal/submodule/git/switch_test.go @@ -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) + } +} diff --git a/internal/submodule/provider.go b/internal/submodule/provider.go index c9c2759..a8003e6 100644 --- a/internal/submodule/provider.go +++ b/internal/submodule/provider.go @@ -1,6 +1,9 @@ package submodule -import "context" +import ( + "context" + "time" +) // Provider supplies repository data. Tree is expected to be fast (structure // 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 } +// 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 // the superproject, whose target is always HEAD. type RestoreTarget int diff --git a/internal/tui/app.go b/internal/tui/app.go index 6efeed4..d63f341 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -15,6 +15,7 @@ import ( zone "github.com/lrstanley/bubblezone" "submarine/internal/submodule" + "submarine/internal/tui/branches" "submarine/internal/tui/detail" "submarine/internal/tui/footer" "submarine/internal/tui/header" @@ -56,8 +57,10 @@ type Model struct { // exactly then. The input is focused while typing and blurred after enter. filtering bool - restore *restoreState // the restore in progress, nil if none - notice string // an error to show above the footer until the next key + 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 width, height int showDetail bool @@ -159,7 +162,7 @@ func (m *Model) startFetch() tea.Cmd { // busy reports whether something shows a spinner. 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 } 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: return m.changed(msg) + case branchesMsg: + return m.branchesLoaded(msg) + case lossMsg: return m.lossChecked(msg) @@ -237,6 +243,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, cmd case tea.MouseMsg: + if m.picker != nil { + return m, nil + } m.tree.Update(msg) 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) { k := m.keys + if m.picker != nil { + return m.pickBranch(msg) + } if m.restore != nil { switch m.restore.phase { case restoreChoosing: @@ -400,6 +412,8 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, m.filter.Focus() case key.Matches(msg, k.Restore): return m.startRestore() + case key.Matches(msg, k.Switch): + return m.startSwitch() case key.Matches(msg, k.Issues): m.tree.SetProblemsOnly(!m.tree.ProblemsOnly()) m.layout() @@ -547,5 +561,9 @@ func (m Model) View() string { } else if m.tree.ProblemsOnly() { 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 } diff --git a/internal/tui/branches/branches.go b/internal/tui/branches/branches.go new file mode 100644 index 0000000..59dbd0d --- /dev/null +++ b/internal/tui/branches/branches.go @@ -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 +} diff --git a/internal/tui/branches/branches_test.go b/internal/tui/branches/branches_test.go new file mode 100644 index 0000000..0a38e00 --- /dev/null +++ b/internal/tui/branches/branches_test.go @@ -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) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 0d16028..68cee84 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -7,7 +7,8 @@ type keyMap struct { CollapseAll, ExpandAll key.Binding Home, End, PageUp, PageDown 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 { @@ -28,6 +29,7 @@ func defaultKeys() keyMap { Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")), Restore: key.NewBinding(key.WithKeys("x"), key.WithHelp("x", "restore")), + Switch: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "switch branch")), Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")), @@ -44,7 +46,7 @@ func (k keyMap) Navigation() []key.Binding { // Submodule returns the bindings that act on the selected submodule. They // are shown in the detail pane, or in the footer if the pane can't show them. -func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.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. 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.CollapseAll, k.ExpandAll}, {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.Help, k.Quit}, } diff --git a/internal/tui/overlay.go b/internal/tui/overlay.go new file mode 100644 index 0000000..9bbbe05 --- /dev/null +++ b/internal/tui/overlay.go @@ -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") +} diff --git a/internal/tui/overlay_test.go b/internal/tui/overlay_test.go new file mode 100644 index 0000000..3cbecda --- /dev/null +++ b/internal/tui/overlay_test.go @@ -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) + } +} diff --git a/internal/tui/restore.go b/internal/tui/restore.go index a1ab739..6dbb501 100644 --- a/internal/tui/restore.go +++ b/internal/tui/restore.go @@ -27,6 +27,7 @@ type restoreState struct { phase restorePhase to submodule.RestoreTarget 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. @@ -37,8 +38,9 @@ type lossMsg struct { } type restoreDoneMsg struct { - path string - err error + path string + branch string // the branch a switch ended on, "" if detached + err error } // 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) { m.restore.phase = restoreChecking m.layout() - r := m.provider.(submodule.Restorer) - ctx, path, to := m.ctx, m.restore.path, m.restore.to + ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch 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 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) { m.restore.phase = restoreRunning m.layout() - r := m.provider.(submodule.Restorer) - ctx, path, to := m.ctx, m.restore.path, m.restore.to - run := func() tea.Msg { return restoreDoneMsg{path, r.Restore(ctx, path, to)} } + ctx, path, to, b := m.ctx, m.restore.path, m.restore.to, m.restore.branch + run := func() tea.Msg { + 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()) } // restoreDone shows the result and reloads everything: the structure may have // changed. func (m Model) restoreDone(msg restoreDoneMsg) (tea.Model, tea.Cmd) { - name := "the superproject" - if m.restore != nil { - name = m.restore.name + m.restore.toText() - } + r := m.restore m.restore = nil var cmds []tea.Cmd - if msg.err != nil { - m.notice = "restore of " + name + " failed: " + msg.err.Error() - } else { - cmds = append(cmds, m.footer.Flash("restored "+name)) + 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 { + m.notice = "restore of " + name + " failed: " + msg.err.Error() + } else { + cmds = append(cmds, m.footer.Flash("restored "+name)) + } } cmds = append(cmds, m.reloadTree(true)) m.layout() 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. func (r *restoreState) toText() string { + if r.branch != nil { + return " to " + r.branch.Name + } if r.to == submodule.RestoreCommitted { return " to the committed pin" } return "" } +// verb and verbIng word the operation: a switch is shown like a restore, but +// names its branch. +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 { if s == nil || s.IsRoot() { return "the superproject" @@ -194,11 +239,11 @@ func (m Model) restoreView() string { case restoreChecking: left = theme.Fg(m.theme.Primary).Render(m.spinner.View()) + " " + theme.Fg(m.theme.Muted).Render("checking "+r.name+"…") 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: - 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)+".") - 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") } return m.line(left, right) diff --git a/internal/tui/restore_test.go b/internal/tui/restore_test.go index f69ad35..5d61ac0 100644 --- a/internal/tui/restore_test.go +++ b/internal/tui/restore_test.go @@ -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 { for _, msg := range run(cmd) { switch msg.(type) { - case lossMsg, restoreDoneMsg: + case lossMsg, restoreDoneMsg, branchesMsg: m, _ = m.Update(msg) } } diff --git a/internal/tui/switch.go b/internal/tui/switch.go new file mode 100644 index 0000000..3a595e3 --- /dev/null +++ b/internal/tui/switch.go @@ -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 +} diff --git a/internal/tui/switch_test.go b/internal/tui/switch_test.go new file mode 100644 index 0000000..afa6bc3 --- /dev/null +++ b/internal/tui/switch_test.go @@ -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) + } +} diff --git a/internal/tui/theme/theme.go b/internal/tui/theme/theme.go index 7725f2f..7f15a86 100644 --- a/internal/tui/theme/theme.go +++ b/internal/tui/theme/theme.go @@ -8,8 +8,9 @@ import ( ) // Theme uses the 16 ANSI colors for everything that carries meaning, so -// the TUI follows the user's terminal color scheme. Only background shades, -// which have no ANSI equivalent, use adaptive 256-color values. +// the TUI follows the user's terminal color scheme. Only background shades +// and the dimmed backdrop, which have no ANSI equivalent, use adaptive +// 256-color values. type Theme struct { Primary lipgloss.TerminalColor // accent: branch names, commit hashes Info lipgloss.TerminalColor @@ -21,6 +22,7 @@ type Theme struct { Border lipgloss.TerminalColor // pane separators, help separators Bar lipgloss.TerminalColor // background of the header bar Selected lipgloss.TerminalColor // background of the selected row + Backdrop lipgloss.TerminalColor // the screen behind a popup, dimmed Glyphs Glyphs } @@ -40,6 +42,7 @@ func Default() Theme { Border: lipgloss.Color("8"), Bar: lipgloss.AdaptiveColor{Light: "254", Dark: "235"}, Selected: lipgloss.AdaptiveColor{Light: "253", Dark: "237"}, + Backdrop: lipgloss.AdaptiveColor{Light: "252", Dark: "238"}, Glyphs: g, } }