diff --git a/AGENTS.md b/AGENTS.md index dfd457f..7511ebf 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,7 +1,7 @@ # AGENTS.md submarine: a Go + Bubble Tea TUI for repositories with many git submodules. -Currently a prototype with a read-only git provider and mock data. +Currently a prototype with a git provider (with background fetch) and mock data. ## Read first @@ -20,6 +20,7 @@ and decisions. Read the relevant document before changing behavior: ```sh go run ./cmd/submarine [dir] # run on a repository (default: cwd) go run ./cmd/submarine --mock # run on mock data +go run ./cmd/submarine --no-fetch [dir] # without background fetch scripts/make-fixture.sh DIR # build a test superproject in DIR go vet ./... go test ./... @@ -34,8 +35,9 @@ gofmt -l . # must print nothing - **Layering.** `internal/submodule` has no UI imports and decides what a state means (`Status.Issues()`). `internal/tui/theme` decides how it looks. Components never classify states or define colors themselves. -- **Data access** only through `submodule.Provider`. The git provider is - read-only and must never use the network. +- **Data access** only through `submodule.Provider` (and its optional + `Fetcher` / `Watcher`). The git provider only uses the network in + `Fetch`, and git must never prompt. - **Colors** only from `theme.Theme`, ANSI 16 colors for meaning (see `docs/ux-guide.md#colors`). Render segments on a background with a `base` style (`LevelStyle(level, base)`, `Delta(pin, base)`). diff --git a/cmd/submarine/main.go b/cmd/submarine/main.go index 4b4dfee..19c9cc2 100644 --- a/cmd/submarine/main.go +++ b/cmd/submarine/main.go @@ -19,8 +19,9 @@ import ( func main() { useMock := flag.Bool("mock", false, "use built-in mock data instead of a real repository") + noFetch := flag.Bool("no-fetch", false, "don't fetch remotes in the background on startup") flag.Usage = func() { - fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [dir]") + fmt.Fprintln(flag.CommandLine.Output(), "usage: submarine [--mock] [--no-fetch] [dir]") flag.PrintDefaults() } flag.Parse() @@ -55,7 +56,7 @@ func main() { zone.NewGlobal() defer zone.Close() - p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion()) + p := tea.NewProgram(tui.New(ctx, provider, tui.Options{Fetch: !*noFetch}), tea.WithAltScreen(), tea.WithMouseCellMotion()) if _, err := p.Run(); err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) diff --git a/docs/README.md b/docs/README.md index af1c213..b40248d 100644 --- a/docs/README.md +++ b/docs/README.md @@ -18,13 +18,13 @@ bare commit hash. ## Status **Prototype.** The UI is complete for browsing. It reads real repositories -through a read-only git provider (`internal/submodule/git`): -`submarine [dir]`. `submarine --mock` shows the built-in mock data -(`internal/submodule/mock`). Actions (`u`, `b`, `f`, `/`, `!`) only show a -"not implemented" message. +through a git provider (`internal/submodule/git`): `submarine [dir]`. It +fetches in the background on startup (`--no-fetch` to skip; `f` to fetch +again) and follows changes made in other shells. `submarine --mock` shows +the built-in mock data (`internal/submodule/mock`). The actions `u`, `b`, +`/`, `!` only show a "not implemented" message. Next steps, roughly in order: -1. Actions: fetch, checkout pin (`submodule update`), bump pin to tip, init. +1. Actions: checkout pin (`submodule update`), bump pin to tip, init. 2. Filter (`/`) and "problems only" (`!`) views. -3. Refresh after actions and on demand. diff --git a/docs/architecture.md b/docs/architecture.md index 6a88334..7f661eb 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -13,15 +13,19 @@ internal/submodule/ domain model; NO UI imports model.go Repo, Submodule, Status, PinInfo, Commit, enums status.go Issues(), HiddenIssues(), Walk() provider.go Provider interface -internal/submodule/git/ read-only provider using the git CLI +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 + gitdir.go resolves .git dirs and gitdir: files internal/submodule/mock/ fixture provider with artificial latency internal/tui/ Bubble Tea application app.go root model: layout, message routing, loading keys.go key bindings (also feed the help view) - msgs.go treeLoadedMsg, statusLoadedMsg + 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 @@ -47,6 +51,18 @@ type Provider interface { } ``` +Optional capabilities; the app type-asserts them: + +```go +type Fetcher interface { + Fetchable(path string) bool // initialized? ("" = superproject) + Fetch(ctx context.Context, path string) error +} +type Watcher interface { + Watch(ctx context.Context) <-chan Change // {Path, Local, Structure} +} +``` + - `Tree` returns the superproject and all submodules with `Parent` and `Children` linked. `Status` fields are nil. - `Status` is called once per submodule, concurrently. It must be safe to @@ -61,7 +77,7 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps `internal/submodule/git` shells out to `git -C …` (see D14). - Environment: `GIT_TERMINAL_PROMPT=0`, `GIT_OPTIONAL_LOCKS=0` (reads never - take `index.lock`), `LC_ALL=C`. No command touches the network. + take `index.lock`), `LC_ALL=C`. Only `Fetch` uses the network. - `Tree` reads each repository's `.gitmodules` (`git config -f .gitmodules`) and recurses into initialized submodules. It records, per path, the parent repository and the `.gitmodules` branch for `Status`. @@ -72,6 +88,22 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps respect `ctx` (D16). - Superproject: branch from `symbolic-ref`, ahead/behind against `@{upstream}`, "fetched" from the mtime of `FETCH_HEAD`. +- `Fetch`: `git fetch --all --prune --no-recurse-submodules --quiet` (we + fetch every submodule ourselves), at most 8 at once, 2 min timeout each. + To never prompt, `GIT_SSH_COMMAND="ssh -o BatchMode=yes"` is set unless + the user configured `GIT_SSH_COMMAND` or `core.sshCommand`. The error is + git's first line of output. +- `Watch` polls every 2 s (`WatchInterval`) and compares a fingerprint per + repository: names, mtimes and sizes of the git metadata files (D19). + - remote part: `FETCH_HEAD`, `refs/remotes/**`, `refs/tags/**` + - local part: `HEAD`, `index`, `MERGE_HEAD`, `packed-refs`, + `refs/heads/**`, `reftable/**` + - structure: the worktree `.gitmodules`, and whether `.git` exists + + Git dirs are resolved from `.git` (directory, or `gitdir:` file for + submodules, plus `commondir` for worktrees) without running git. Our own + reads write nothing (`GIT_OPTIONAL_LOCKS=0`), so they never trigger a + change. Worktree file edits are not watched (`r` reloads). ## Loading flow @@ -80,7 +112,38 @@ The mock provider (`internal/submodule/mock`) covers every state and sleeps calling `Status()` → `statusLoadedMsg`. 3. Each result is stored on the submodule (`Status` or `Err`); the view updates incrementally, including parents' hidden-issue counts. -4. The spinner stops ticking when nothing is loading. +4. After the first tree: the watcher starts (`changeMsg`), and the + background fetch starts one `tea.Cmd` per fetchable repository + (`fetchDoneMsg`). +5. The spinner ticks only while something loads or fetches. + +### Fetch and refresh are decoupled (D20) + +``` +Fetch (network) ──writes refs──▶ .git/… ◀──writes refs── user's shell + │ + Watch (polls every 2 s) + │ Change{Path, Local, Structure} + ▼ + app reloads Status / Tree +``` + +- `fetchDoneMsg` only updates the progress and `Submodule.FetchErr`; it + never reloads anything. The watcher sees the new refs and triggers the + reload, exactly as for a fetch made in another shell. +- `changeMsg`: + - submodule, remote only → reload its `Status` + - submodule, local → reload it and its direct children (their pins live + in its index) + - superproject, or `.gitmodules` anywhere → reload the tree; if local, + also every status +- A reloaded tree carries the old `Status`, `Err` and `FetchErr` over by + path, and `tree.ReplaceRoots` keeps expansion and the cursor, so a reload + doesn't flash spinners or move the selection. +- The `reloader` runs at most one load per key (a submodule path, or the + tree). A request during a load marks it dirty; it runs once more + afterwards. Results therefore never arrive out of order and bursts of + changes (a fetch writing many refs) coalesce. ## Tree component @@ -114,7 +177,10 @@ go test ./... - `internal/submodule`: issue classification and hidden-issue aggregation. - `internal/submodule/git`: parser unit tests, and integration tests against a fixture repository built by `scripts/make-fixture.sh` (skipped - if `git` is not installed). + if `git` is not installed). Fetch and watch tests build their own + fixture, since they change it; the fixture's remotes are local bare + repositories, so fetching works offline. +- `internal/tui`: `reloader`. - `internal/tui/tree`: navigation rules. Tests need `zone.NewGlobal()` (see `TestMain`). diff --git a/docs/decisions.md b/docs/decisions.md index ce9746e..9a19b8a 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -90,7 +90,7 @@ artificial latency. **Consequences.** A git backend can be added without UI changes; the UI stays responsive with many submodules. -## D12 — No network on startup +## D12 — No network on startup (superseded by D18) **Decision.** Work from local refs and show their age; fetching is an explicit action (`f`). @@ -146,3 +146,40 @@ Dirt inside nested submodules is ignored (D7); unstaged moves show as removed + untracked. **Consequences.** One extra `git diff` per dirty submodule, none for clean ones. Not included: staged/unstaged markers, diff bodies, stash count. + +## D18 — Background fetch on startup + +**Context.** Without fetching, every answer is only as fresh as the user's +last `git fetch`, which they rarely run in every submodule. +**Decision.** Supersedes D12. Show the local state at once, then fetch the +superproject and every initialized submodule in the background (all +remotes, prune, no recursion: we fetch each repository ourselves, at most 8 +at once). Fetches never prompt; they fail with git's message instead. +`--no-fetch` disables it, `f` repeats it. +**Consequences.** Startup still doesn't wait for the network (N1). A +failed fetch is shown per submodule and counted in the header; it is not an +issue (D3). + +## D19 — Watch git metadata by polling + +**Context.** The view should follow changes made outside the application. +File system events (fsnotify) need a dependency, one watch per directory +(hundreds of repositories × ref directories hit inotify limits) and don't +work on WSL's Windows drives or network file systems. +**Decision.** Poll every 2 s: compare names, mtimes and sizes of each +repository's git metadata (`FETCH_HEAD`, refs, `HEAD`, `index`, …), +split into a remote, a local and a structure part. +**Consequences.** Works everywhere, no dependency; a few `stat`s per +repository per poll; changes appear within about 2 s. Edits of worktree +files are not detected (that would mean running `git status` everywhere +every poll); `r` reloads on demand. + +## D20 — Refresh is driven by the repositories, not by our fetch + +**Decision.** A finished fetch only updates its progress. Reloading is +triggered solely by the watcher, so our own fetch and a fetch in another +shell take the same path. Reloads keep the old state visible, run at most +once per submodule at a time and coalesce bursts. +**Consequences.** One mechanism to test and trust. After our fetch the +data updates up to one poll interval later; the header shows "fetched just +now" right away. diff --git a/docs/requirements.md b/docs/requirements.md index d0b0d0f..c5c1a74 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -85,13 +85,28 @@ commit hashes. It doesn't answer the questions users actually have: - **F41** Mouse: click a row to select it, click `▸`/`▾` to toggle, scroll wheel moves the selection. +### Fetch and refresh + +- **F50** On startup, after the local state is shown, the superproject and + every initialized submodule are fetched in the background (all remotes, + with prune). `--no-fetch` turns this off. +- **F51** While fetching, the header shows progress (`fetching 12/174`) + instead of the fetch age; afterwards the age and the number of failed + fetches. The detail pane shows each submodule's last fetch and, if it + failed, git's message. +- **F52** The view follows changes in the repositories, no matter who made + them: a fetch, commit, checkout or `git add` in another shell shows up + within about two seconds. Refreshing is driven by the repositories, not by + the application's own fetch. +- **F53** `f` fetches again; `r` reloads everything (e.g. after editing + files, which is not watched). + ### Actions (planned, not implemented) | Key | Action | |---|---| | `u` | Check out the pinned commit (`git submodule update`) | | `b` | Bump the pin to the tip of its ref | -| `f` | Fetch remotes | | `/` | Filter by name/path | | `!` | Show only submodules with issues | @@ -100,12 +115,13 @@ commit hashes. It doesn't answer the questions users actually have: ```sh submarine [dir] # the repository containing dir (default: current directory) submarine --mock # built-in mock data +submarine --no-fetch [dir] # no background fetch on startup ``` ## Non-functional requirements -- **N1** Startup must not block on the network. The tool works from local - refs and shows how old they are ("fetched 2m ago"); fetching is explicit. +- **N1** Startup must not block on the network. The tool shows the local + state at once and fetches in the background (F50). - **N2** Loading many submodules must not freeze the UI (async, per submodule). - **N3** Colors follow the user's terminal theme (ANSI 16 colors); the UI @@ -116,5 +132,7 @@ submarine --mock # built-in mock data - **N6** Works in narrow terminals (detail pane hides below 90 columns). - **N7** The UI is independent of the data source: the mock and the git provider are interchangeable without UI changes. -- **N8** The git provider is read-only and never uses the network; it uses - the `git` command line, so the user's git configuration applies. +- **N8** The git provider only uses the network in `Fetch`, and never + prompts (no password, passphrase or host-key questions; those fetches + fail with git's message). It uses the `git` command line, so the user's + git configuration applies. Apart from fetching, it is read-only. diff --git a/docs/state-model.md b/docs/state-model.md index c72c288..47d62fa 100644 --- a/docs/state-model.md +++ b/docs/state-model.md @@ -33,6 +33,11 @@ in several at once. | `Pinned`, `CheckedOut`, `Tip` | Commits (SHA, subject, author, time) | | `Between` | Commits that make up the pin ↔ tip difference | | `Changes` | Changed files of the worktree (nil if clean), see below | +| `FetchedAt` | Last fetch of the submodule (`FETCH_HEAD` mtime); zero if never | + +`Submodule.FetchErr` is set if the application's last fetch of the +submodule failed. Like loading errors, it is not an issue kind: a failed +fetch says nothing about the submodule's state. `Status == nil` means "still loading". `Submodule.Err` is set if loading failed. diff --git a/docs/ux-guide.md b/docs/ux-guide.md index 1c98197..a6448b4 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -63,7 +63,8 @@ indent ▸ name [n] status ref delta `detached HEAD on pin` as information 3. `Changes` (only with uncommitted changes), see [Change preview](#change-preview) -4. `Ref` with distance and `via ` +4. `Ref` with distance and `via `; `Fetched ` of the submodule; + `fetch failed: ` (yellow word) if the last fetch failed 5. `Pinned`, `Checkout`, `Tip`: SHA + subject; the checkout SHA is yellow if it differs from the pin 6. "Pin is n commits behind/ahead of …" / "Pin and … have diverged": commit list @@ -115,12 +116,23 @@ Changes 5 files · 2M 1A 1D 1R · +37 −12 - Untracked directories are shown as one entry (`tmp/`). ### Header -`path branch ↑↓ · n submodules · … · n pin staged · n loading · fetched ` +`path branch ↑↓ · n submodules · … · n pin staged · n loading · ` Issue counts are ordered by severity (unpushed, conflict, missing, off pin, -changed), each in its level color. Zero counts are omitted. `` is -`never` if the superproject has never been fetched; a detached superproject -shows `(detached)` as its branch. +changed), each in its level color. Zero counts are omitted. A detached +superproject shows `(detached)` as its branch. + +`` on the right: + +| State | Shows | +|---|---| +| fetching | spinner + `fetching 12/174` (repositories done / total) | +| done | `fetched just now`, then the age (`fetched 5m ago`) | +| some failed | `fetched just now · 3 failed` (count in yellow) | +| never fetched | `fetched never` | + +The age is the newer of the superproject's `FETCH_HEAD` and the end of the +application's own fetch. ## Colors @@ -171,7 +183,9 @@ colors in components. | `pgup`, `pgdn` | Move by one screen | | `?` | Toggle full help | | `q`, `ctrl+c` | Quit | -| `u`, `b`, `f`, `/`, `!` | Planned actions (see requirements) | +| `f` | Fetch all repositories (does nothing while a fetch runs) | +| `r` | Reload everything (e.g. after editing files) | +| `u`, `b`, `/`, `!` | Planned actions (see requirements) | "First child" always means the first child **as displayed** (children are grouped by directory). diff --git a/internal/submodule/git/exec.go b/internal/submodule/git/exec.go index ac33a27..a9b7cdf 100644 --- a/internal/submodule/git/exec.go +++ b/internal/submodule/git/exec.go @@ -4,9 +4,9 @@ import ( "bytes" "context" "errors" - "fmt" "os" "os/exec" + "slices" "strings" ) @@ -18,22 +18,40 @@ var gitEnv = append(os.Environ(), "LC_ALL=C", ) +// gitError is a failed git command; Stderr is git's message. +type gitError struct { + Args []string + Stderr string + Err error +} + +func (e *gitError) Error() string { + msg := e.Stderr + if msg == "" { + msg = e.Err.Error() + } + return "git " + strings.Join(e.Args, " ") + ": " + msg +} + +func (e *gitError) Unwrap() error { return e.Err } + // run executes git in dir and returns stdout with the trailing newline // removed. The error includes git's stderr. func run(ctx context.Context, dir string, args ...string) (string, error) { + return runEnv(ctx, dir, nil, args...) +} + +// runEnv is run with additional environment variables. +func runEnv(ctx context.Context, dir string, env []string, args ...string) (string, error) { cmd := exec.CommandContext(ctx, "git", append([]string{"-C", dir}, args...)...) - cmd.Env = gitEnv + cmd.Env = slices.Concat(gitEnv, env) // a fresh slice: run is called concurrently var stdout, stderr bytes.Buffer cmd.Stdout, cmd.Stderr = &stdout, &stderr if err := cmd.Run(); err != nil { if ctx.Err() != nil { return "", ctx.Err() } - msg := strings.TrimSpace(stderr.String()) - if msg == "" { - msg = err.Error() - } - return "", fmt.Errorf("git %s: %s", strings.Join(args, " "), msg) + return "", &gitError{Args: args, Stderr: strings.TrimSpace(stderr.String()), Err: err} } return strings.TrimRight(stdout.String(), "\n"), nil } diff --git a/internal/submodule/git/fetch.go b/internal/submodule/git/fetch.go new file mode 100644 index 0000000..29b31c4 --- /dev/null +++ b/internal/submodule/git/fetch.go @@ -0,0 +1,79 @@ +package git + +import ( + "context" + "errors" + "fmt" + "os" + "strings" + "time" +) + +// fetchTimeout bounds a single fetch, e.g. against an unreachable host. +const fetchTimeout = 2 * time.Minute + +// maxFetches bounds concurrent fetches; they wait on the network, not the +// CPU. +const maxFetches = 8 + +// sshBatchEnv makes ssh fail instead of asking for a password or host key, +// which would corrupt the terminal UI. It is nil if the user configured +// their own ssh command. +func sshBatchEnv(root string) []string { + if os.Getenv("GIT_SSH_COMMAND") != "" { + return nil + } + if out, ok, _ := try(context.Background(), root, "config", "core.sshCommand"); ok && out != "" { + return nil + } + return []string{"GIT_SSH_COMMAND=ssh -o BatchMode=yes"} +} + +// Fetchable reports whether path ("" is the superproject) is a checked-out +// repository. +func (p *Provider) Fetchable(path string) bool { + _, ok := p.repoDir(path) + return ok +} + +// repoDir returns the worktree of path if it is initialized. +func (p *Provider) repoDir(path string) (string, bool) { + if path == "" { + return p.root, true + } + p.mu.RLock() + in, ok := p.mods[path] + p.mu.RUnlock() + if !ok || !initialized(in.dir) { + return "", false + } + return in.dir, true +} + +// Fetch fetches all remotes of one repository. Submodules are fetched +// separately, not recursively. The error is git's first line of output. +func (p *Provider) Fetch(ctx context.Context, path string) error { + dir, ok := p.repoDir(path) + if !ok { + return fmt.Errorf("%s is not initialized", path) + } + select { + case p.fetchSem <- struct{}{}: + defer func() { <-p.fetchSem }() + case <-ctx.Done(): + return ctx.Err() + } + ctx, cancel := context.WithTimeout(ctx, fetchTimeout) + defer cancel() + + _, err := runEnv(ctx, dir, p.sshEnv, "fetch", "--all", "--prune", "--no-recurse-submodules", "--quiet") + var ge *gitError + switch { + case errors.Is(err, context.DeadlineExceeded): + return fmt.Errorf("timed out after %s", fetchTimeout) + case errors.As(err, &ge) && ge.Stderr != "": + line, _, _ := strings.Cut(ge.Stderr, "\n") + return errors.New(strings.TrimPrefix(line, "fatal: ")) + } + return err +} diff --git a/internal/submodule/git/fetch_test.go b/internal/submodule/git/fetch_test.go new file mode 100644 index 0000000..633c42d --- /dev/null +++ b/internal/submodule/git/fetch_test.go @@ -0,0 +1,161 @@ +package git + +import ( + "context" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "submarine/internal/submodule" +) + +// ownFixture builds a fixture the test may modify. It returns the provider +// (after Tree) and the fixture root (superproject in root/super, work +// clones of the remotes in root/work). +func ownFixture(t *testing.T) (*Provider, string) { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("git not available") + } + root := filepath.Join(t.TempDir(), "fx") + if err := makeFixture(root); err != nil { + t.Fatal(err) + } + p, err := New(filepath.Join(root, "super")) + if err != nil { + t.Fatal(err) + } + if _, err := p.Tree(context.Background()); err != nil { + t.Fatal(err) + } + return p, root +} + +func git(t *testing.T, dir string, args ...string) { + t.Helper() + args = append([]string{"-C", dir, "-c", "user.name=t", "-c", "user.email=t@t"}, args...) + if out, err := exec.Command("git", args...).CombinedOutput(); err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } +} + +// pushUpstream adds a commit to a fixture remote, as if someone else pushed. +func pushUpstream(t *testing.T, root, remote string) { + t.Helper() + work := filepath.Join(root, "work", remote) + git(t, work, "fetch", "-q") + git(t, work, "reset", "-q", "--hard", "origin/main") + git(t, work, "commit", "--allow-empty", "-m", "Upstream change") + git(t, work, "push", "-q", "origin", "main") +} + +func TestFetch(t *testing.T) { + p, root := ownFixture(t) + ctx := context.Background() + + before, err := p.Status(ctx, "src/core") + if err != nil { + t.Fatal(err) + } + pushUpstream(t, root, "core") + if err := p.Fetch(ctx, "src/core"); err != nil { + t.Fatalf("Fetch: %v", err) + } + after, err := p.Status(ctx, "src/core") + if err != nil { + t.Fatal(err) + } + if after.Pin.Behind <= before.Pin.Behind { + t.Errorf("after fetch: ↓%d, want more than ↓%d", after.Pin.Behind, before.Pin.Behind) + } + if after.FetchedAt.IsZero() { + t.Error("after fetch: FetchedAt is zero") + } + + if err := p.Fetch(ctx, ""); err != nil { + t.Errorf("Fetch(superproject without remotes): %v", err) + } + if p.Fetchable("libs/missing") { + t.Error("Fetchable(libs/missing) = true for an uninitialized submodule") + } + if err := p.Fetch(ctx, "libs/missing"); err == nil { + t.Error("Fetch(libs/missing): want error") + } +} + +func TestFetchError(t *testing.T) { + p, root := ownFixture(t) + git(t, filepath.Join(root, "super", "src", "ui"), "remote", "set-url", "origin", filepath.Join(root, "nowhere.git")) + + err := p.Fetch(context.Background(), "src/ui") + if err == nil { + t.Fatal("Fetch with a bad remote: want error") + } + if strings.Contains(err.Error(), "\n") || strings.HasPrefix(err.Error(), "git ") { + t.Errorf("error should be git's first line only, got %q", err) + } +} + +// next waits for a change or fails after timeout. +func next(t *testing.T, ch <-chan submodule.Change) submodule.Change { + t.Helper() + select { + case c := <-ch: + return c + case <-time.After(3 * time.Second): + t.Fatal("no change reported") + return submodule.Change{} + } +} + +func TestWatch(t *testing.T) { + p, root := ownFixture(t) + super := filepath.Join(root, "super") + p.WatchInterval = 20 * time.Millisecond + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + ch := p.Watch(ctx) + + // our own reads must not look like changes + for _, path := range []string{"src/core", "libs/work", "libs/conflict"} { + if _, err := p.Status(ctx, path); err != nil { + t.Fatal(err) + } + } + select { + case c := <-ch: + t.Fatalf("change while idle: %+v", c) + case <-time.After(200 * time.Millisecond): + } + + // a fetch in another shell + pushUpstream(t, root, "net") + git(t, filepath.Join(super, "src", "net"), "fetch", "-q") + if c := next(t, ch); c != (submodule.Change{Path: "src/net"}) { + t.Errorf("after fetch: %+v, want remote change of src/net", c) + } + + // a commit in another shell + git(t, filepath.Join(super, "libs", "store"), "commit", "-q", "--allow-empty", "-m", "Local") + if c := next(t, ch); c != (submodule.Change{Path: "libs/store", Local: true}) { + t.Errorf("after commit: %+v, want local change of libs/store", c) + } + + cancel() + for range ch { // closed after cancel + } +} + +func TestGitDirs(t *testing.T) { + p, _ := loadFixture(t) + gd, cd, err := gitDirs(p.root) + if err != nil || gd != filepath.Join(p.root, ".git") || cd != gd { + t.Errorf("superproject: %q, %q, %v", gd, cd, err) + } + gd, cd, err = gitDirs(filepath.Join(p.root, "src", "core")) + if err != nil || gd != filepath.Join(p.root, ".git", "modules", "src", "core") || cd != gd { + t.Errorf("submodule: %q, %q, %v", gd, cd, err) + } +} diff --git a/internal/submodule/git/gitdir.go b/internal/submodule/git/gitdir.go new file mode 100644 index 0000000..b8849c0 --- /dev/null +++ b/internal/submodule/git/gitdir.go @@ -0,0 +1,43 @@ +package git + +import ( + "errors" + "os" + "path/filepath" + "strings" +) + +// gitDirs returns the git directory of the worktree at dir and its common +// directory (they differ only for linked worktrees). A submodule's .git is +// a file "gitdir: ". +func gitDirs(dir string) (gitDir, commonDir string, err error) { + dotGit := filepath.Join(dir, ".git") + fi, err := os.Stat(dotGit) + if err != nil { + return "", "", err + } + gitDir = dotGit + if !fi.IsDir() { + b, err := os.ReadFile(dotGit) + if err != nil { + return "", "", err + } + p, ok := strings.CutPrefix(strings.TrimSpace(string(b)), "gitdir:") + if !ok { + return "", "", errors.New(dotGit + ": not a gitdir file") + } + gitDir = joinPath(dir, strings.TrimSpace(p)) + } + commonDir = gitDir + if b, err := os.ReadFile(filepath.Join(gitDir, "commondir")); err == nil { + commonDir = joinPath(gitDir, strings.TrimSpace(string(b))) + } + return gitDir, commonDir, nil +} + +func joinPath(base, p string) string { + if !filepath.IsAbs(p) { + p = filepath.Join(base, p) + } + return filepath.Clean(p) +} diff --git a/internal/submodule/git/provider.go b/internal/submodule/git/provider.go index e487973..3d59601 100644 --- a/internal/submodule/git/provider.go +++ b/internal/submodule/git/provider.go @@ -14,6 +14,7 @@ import ( "slices" "strings" "sync" + "time" "submarine/internal/submodule" ) @@ -27,8 +28,13 @@ const maxBetween = 50 // Provider reads a superproject and its submodules with git. type Provider struct { - root string // absolute superproject root - sem chan struct{} // bounds concurrent Status calls + root string // absolute superproject root + sem chan struct{} // bounds concurrent Status calls + fetchSem chan struct{} // bounds concurrent fetches + sshEnv []string // extra environment for fetches + + // WatchInterval is how often Watch checks for changes. + WatchInterval time.Duration mu sync.RWMutex mods map[string]info // by Submodule.Path; filled by Tree @@ -49,8 +55,11 @@ func New(dir string) (*Provider, error) { return nil, fmt.Errorf("%s is not a git repository", dir) } return &Provider{ - root: root, - sem: make(chan struct{}, runtime.NumCPU()), + root: root, + sem: make(chan struct{}, runtime.NumCPU()), + fetchSem: make(chan struct{}, maxFetches), + sshEnv: sshBatchEnv(root), + WatchInterval: 2 * time.Second, }, nil } @@ -168,6 +177,12 @@ func status(ctx context.Context, in info) (*submodule.Status, error) { return st, nil } + if gitDir, _, err := gitDirs(in.dir); err == nil { + if fi, err := os.Stat(filepath.Join(gitDir, "FETCH_HEAD")); err == nil { + st.FetchedAt = fi.ModTime() + } + } + // checkout and worktree head, err := run(ctx, in.dir, "rev-parse", "HEAD") if err != nil { diff --git a/internal/submodule/git/provider_test.go b/internal/submodule/git/provider_test.go index 003d1fd..340dec2 100644 --- a/internal/submodule/git/provider_test.go +++ b/internal/submodule/git/provider_test.go @@ -27,11 +27,8 @@ func TestMain(m *testing.M) { } defer os.RemoveAll(tmp) if _, err := exec.LookPath("git"); err == nil { - _, file, _, _ := runtime.Caller(0) - script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh") - out, err := exec.Command("bash", script, filepath.Join(tmp, "fx")).CombinedOutput() - if err != nil { - panic("make-fixture.sh: " + err.Error() + "\n" + string(out)) + if err := makeFixture(filepath.Join(tmp, "fx")); err != nil { + panic(err) } fixtureDir = filepath.Join(tmp, "fx", "super") } @@ -40,6 +37,16 @@ func TestMain(m *testing.M) { os.Exit(code) } +// makeFixture runs scripts/make-fixture.sh; the superproject is dir/super. +func makeFixture(dir string) error { + _, file, _, _ := runtime.Caller(0) + script := filepath.Join(filepath.Dir(file), "..", "..", "..", "scripts", "make-fixture.sh") + if out, err := exec.Command("bash", script, dir).CombinedOutput(); err != nil { + return fmt.Errorf("make-fixture.sh: %v\n%s", err, out) + } + return nil +} + func loadFixture(t *testing.T) (*Provider, *submodule.Repo) { t.Helper() if fixtureDir == "" { diff --git a/internal/submodule/git/watch.go b/internal/submodule/git/watch.go new file mode 100644 index 0000000..6a3bd95 --- /dev/null +++ b/internal/submodule/git/watch.go @@ -0,0 +1,123 @@ +package git + +import ( + "context" + "fmt" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + + "submarine/internal/submodule" +) + +// fingerprint summarizes the git metadata of one repository. Each part is +// a list of file names with modification time and size; a change in a part +// means something in that area changed. +type fingerprint struct { + remote string // FETCH_HEAD, remote-tracking branches, tags + local string // HEAD, index, local branches, packed refs, merge state + structure string // .gitmodules and whether the repository is checked out +} + +// Watch polls the git metadata of the superproject and all submodules every +// WatchInterval and reports the repositories that changed. Polling (instead +// of file system events) works on every file system, including WSL's +// Windows drives, and needs no watch handles. +func (p *Provider) Watch(ctx context.Context) <-chan submodule.Change { + ch := make(chan submodule.Change, 16) + go func() { + defer close(ch) + prev := p.fingerprints() + t := time.NewTicker(p.WatchInterval) + defer t.Stop() + for { + select { + case <-ctx.Done(): + return + case <-t.C: + } + cur := p.fingerprints() + for path, f := range cur { + old, ok := prev[path] + if !ok || old == f { + continue // new repositories only get a baseline + } + c := submodule.Change{ + Path: path, + Local: old.local != f.local || old.structure != f.structure, + Structure: old.structure != f.structure, + } + select { + case ch <- c: + case <-ctx.Done(): + return + } + } + prev = cur + } + }() + return ch +} + +// fingerprints returns the fingerprint of every known repository, keyed by +// submodule path ("" is the superproject). +func (p *Provider) fingerprints() map[string]fingerprint { + dirs := map[string]string{"": p.root} + p.mu.RLock() + for path, in := range p.mods { + dirs[path] = in.dir + } + p.mu.RUnlock() + + fps := make(map[string]fingerprint, len(dirs)) + for path, dir := range dirs { + fps[path] = fingerprintOf(dir) + } + return fps +} + +func fingerprintOf(dir string) fingerprint { + var f fingerprint + var structure strings.Builder + stamp(&structure, filepath.Join(dir, ".gitmodules")) + gitDir, common, err := gitDirs(dir) + if err != nil { + f.structure = structure.String() + "not checked out" + return f + } + f.structure = structure.String() + + var remote, local strings.Builder + stamp(&remote, filepath.Join(gitDir, "FETCH_HEAD")) + stampTree(&remote, filepath.Join(common, "refs", "remotes")) + stampTree(&remote, filepath.Join(common, "refs", "tags")) + + for _, name := range []string{"HEAD", "index", "MERGE_HEAD"} { + stamp(&local, filepath.Join(gitDir, name)) + } + stamp(&local, filepath.Join(common, "packed-refs")) + stampTree(&local, filepath.Join(common, "refs", "heads")) + stampTree(&local, filepath.Join(common, "reftable")) // reftable ref storage + f.remote, f.local = remote.String(), local.String() + return f +} + +// stamp appends the name, modification time and size of a file; nothing if +// it doesn't exist. +func stamp(b *strings.Builder, name string) { + if fi, err := os.Stat(name); err == nil { + fmt.Fprintf(b, "%s %d %d\n", name, fi.ModTime().UnixNano(), fi.Size()) + } +} + +// stampTree stamps every file below dir. +func stampTree(b *strings.Builder, dir string) { + filepath.WalkDir(dir, func(name string, d fs.DirEntry, err error) error { + if err == nil && !d.IsDir() { + stamp(b, name) + } + return nil + }) +} diff --git a/internal/submodule/mock/mock.go b/internal/submodule/mock/mock.go index a6e1db3..afd8178 100644 --- a/internal/submodule/mock/mock.go +++ b/internal/submodule/mock/mock.go @@ -4,9 +4,11 @@ package mock import ( "context" + "errors" "fmt" "math/rand/v2" "path" + "sync" "time" "submarine/internal/submodule" @@ -15,7 +17,9 @@ import ( // Provider serves a fixed fixture tree. type Provider struct { MinDelay, MaxDelay time.Duration - statuses map[string]*submodule.Status + + mu sync.RWMutex // Tree can run again (reload) while Status runs + statuses map[string]*submodule.Status } func New() *Provider { @@ -32,6 +36,8 @@ type node struct { } func (p *Provider) Tree(ctx context.Context) (*submodule.Repo, error) { + p.mu.Lock() + defer p.mu.Unlock() p.statuses = map[string]*submodule.Status{} repo := &submodule.Repo{ Path: "~/coding/my_project", @@ -57,6 +63,9 @@ func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule Parent: parent, } m.Children = p.build(n.children, m) + if n.status.Presence == submodule.PresenceOK && n.status.FetchedAt.IsZero() { + n.status.FetchedAt = now.Add(-2 * time.Minute) + } p.statuses[full] = n.status out = append(out, m) } @@ -64,22 +73,54 @@ func (p *Provider) build(nodes []node, parent *submodule.Submodule) []*submodule } func (p *Provider) Status(ctx context.Context, path string) (*submodule.Status, error) { - delay := p.MinDelay - if span := p.MaxDelay - p.MinDelay; span > 0 { - delay += rand.N(span) - } - select { - case <-time.After(delay): - case <-ctx.Done(): - return nil, ctx.Err() + if err := p.sleep(ctx, p.MinDelay, p.MaxDelay); err != nil { + return nil, err } + p.mu.RLock() s, ok := p.statuses[path] + p.mu.RUnlock() if !ok { return nil, fmt.Errorf("unknown submodule %q", path) } return s, nil } +// Fetchable is true for the superproject and initialized submodules. +func (p *Provider) Fetchable(path string) bool { + if path == "" { + return true + } + p.mu.RLock() + defer p.mu.RUnlock() + s, ok := p.statuses[path] + return ok && s.Presence == submodule.PresenceOK +} + +// Fetch pretends to fetch; one submodule always fails. It changes no data. +func (p *Provider) Fetch(ctx context.Context, path string) error { + if err := p.sleep(ctx, 300*time.Millisecond, 2*time.Second); err != nil { + return err + } + if path == "src/my_submodule_3" { + return errors.New("git@github.com: Permission denied (publickey).") + } + return nil +} + +// sleep waits a random duration in [min, max) or until ctx is done. +func (p *Provider) sleep(ctx context.Context, min, max time.Duration) error { + d := min + if span := max - min; span > 0 { + d += rand.N(span) + } + select { + case <-time.After(d): + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + // --- fixture ----------------------------------------------------------------- var now = time.Now() diff --git a/internal/submodule/model.go b/internal/submodule/model.go index 6508567..b658c90 100644 --- a/internal/submodule/model.go +++ b/internal/submodule/model.go @@ -29,6 +29,7 @@ type Submodule struct { Children []*Submodule Status *Status // nil while loading Err error // set if loading the status failed + FetchErr error // set if the last fetch failed; not an issue } // Presence describes whether the submodule exists on disk. @@ -177,6 +178,7 @@ type Status struct { Pinned *Commit CheckedOut *Commit Tip *Commit - Between []Commit // commits that make up the pin <-> tip difference - Changes *Changes // nil if the worktree is clean or the details are unknown + Between []Commit // commits that make up the pin <-> tip difference + Changes *Changes // nil if the worktree is clean or the details are unknown + FetchedAt time.Time // last fetch of the submodule; zero if never } diff --git a/internal/submodule/provider.go b/internal/submodule/provider.go index 0496149..988f1c9 100644 --- a/internal/submodule/provider.go +++ b/internal/submodule/provider.go @@ -8,3 +8,27 @@ type Provider interface { Tree(ctx context.Context) (*Repo, error) Status(ctx context.Context, path string) (*Status, error) } + +// Fetcher is implemented by providers that can download remote refs. Path +// "" is the superproject. Fetch must be safe to call concurrently and must +// never prompt the user. +type Fetcher interface { + // Fetchable reports whether path is a repository that can be fetched + // (e.g. not an uninitialized submodule). + Fetchable(path string) bool + Fetch(ctx context.Context, path string) error +} + +// Watcher is implemented by providers that notice changes made outside the +// application, e.g. a fetch or commit in another shell. The channel is +// closed when ctx is done. +type Watcher interface { + Watch(ctx context.Context) <-chan Change +} + +// Change reports that the git metadata of a repository changed. +type Change struct { + Path string // "" is the superproject + Local bool // HEAD, index or local branches changed, not only remote refs + Structure bool // .gitmodules changed: submodules may have been added or removed +} diff --git a/internal/tui/app.go b/internal/tui/app.go index 473631d..77be594 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -4,6 +4,7 @@ package tui import ( "context" + "time" "github.com/charmbracelet/bubbles/key" "github.com/charmbracelet/bubbles/spinner" @@ -26,16 +27,29 @@ const ( detailWidthRatio = 0.4 ) +// Options configure the application. +type Options struct { + Fetch bool // fetch all repositories in the background on startup +} + type Model struct { ctx context.Context provider submodule.Provider + fetcher submodule.Fetcher // nil if the provider can't fetch + watcher submodule.Watcher // nil if the provider can't watch + opts Options theme theme.Theme keys keyMap - repo *submodule.Repo - byPath map[string]*submodule.Submodule - loading int - err error + repo *submodule.Repo + byPath map[string]*submodule.Submodule + err error + + loads reloader + reloadStatuses bool // the running or pending tree load also reloads every status + changes <-chan submodule.Change + fetch header.Fetch + ticking bool // a spinner tick is scheduled tree tree.Model detail detail.Model @@ -46,23 +60,30 @@ type Model struct { showDetail bool } -func New(ctx context.Context, p submodule.Provider) Model { +func New(ctx context.Context, p submodule.Provider, opts Options) Model { t := theme.Default() k := defaultKeys() sp := spinner.New(spinner.WithSpinner(spinner.MiniDot)) - return Model{ + m := Model{ ctx: ctx, provider: p, + opts: opts, theme: t, keys: k, + loads: newReloader(), + ticking: true, // Init schedules the first tick tree: tree.New(t), detail: detail.New(t), footer: footer.New(t, k), spinner: sp, } + m.fetcher, _ = p.(submodule.Fetcher) + m.watcher, _ = p.(submodule.Watcher) + return m } func (m Model) Init() tea.Cmd { + m.loads.start(treeKey) return tea.Batch(m.loadTree(), m.spinner.Tick) } @@ -80,6 +101,74 @@ func (m Model) loadStatus(path string) tea.Cmd { } } +// reloadTree loads the tree again, unless a load is running (then it runs +// once more afterwards). With statuses, every status is reloaded too. +func (m *Model) reloadTree(statuses bool) tea.Cmd { + m.reloadStatuses = m.reloadStatuses || statuses + if !m.loads.start(treeKey) { + return nil + } + return m.loadTree() +} + +// reloadStatus loads one status again; the old one stays visible meanwhile. +func (m *Model) reloadStatus(path string) tea.Cmd { + if !m.loads.start(path) { + return nil + } + return m.loadStatus(path) +} + +func (m Model) waitForChange() tea.Cmd { + ch := m.changes + return func() tea.Msg { + c, ok := <-ch + return changeMsg{change: c, closed: !ok} + } +} + +// startFetch fetches the superproject and every initialized submodule in +// the background. It does nothing while a fetch is running. +func (m *Model) startFetch() tea.Cmd { + if m.fetcher == nil || m.fetch.Running || m.repo == nil { + return nil + } + paths := []string{""} + submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) { + if m.fetcher.Fetchable(s.Path) { + paths = append(paths, s.Path) + } + }) + m.fetch = header.Fetch{Running: true, Total: len(paths), At: m.fetch.At} + cmds := []tea.Cmd{m.tick()} + for _, p := range paths { + f, ctx := m.fetcher, m.ctx + cmds = append(cmds, func() tea.Msg { return fetchDoneMsg{p, f.Fetch(ctx, p)} }) + } + return tea.Batch(cmds...) +} + +// busy reports whether something shows a spinner. +func (m Model) busy() bool { + if m.repo == nil || m.fetch.Running { + return true + } + loading := false + submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) { + loading = loading || (s.Status == nil && s.Err == nil) + }) + return loading +} + +// tick schedules a spinner tick if none is scheduled. +func (m *Model) tick() tea.Cmd { + if m.ticking { + return nil + } + m.ticking = true + return m.spinner.Tick +} + func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.WindowSizeMsg: @@ -88,25 +177,35 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil case treeLoadedMsg: - if msg.err != nil { - m.err = msg.err - return m, nil - } - m.repo = msg.repo - m.byPath = map[string]*submodule.Submodule{} - var cmds []tea.Cmd - submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) { - m.byPath[s.Path] = s - cmds = append(cmds, m.loadStatus(s.Path)) - }) - m.loading = len(cmds) - m.tree.SetRoots(m.repo.Submodules) - return m, tea.Batch(cmds...) + return m.treeLoaded(msg) case statusLoadedMsg: if s, ok := m.byPath[msg.path]; ok { - s.Status, s.Err = msg.status, msg.err - m.loading-- + if msg.err != nil { + s.Err = msg.err + } else { + s.Status, s.Err = msg.status, nil + } + } + if m.loads.done(msg.path) { + return m, m.loadStatus(msg.path) + } + return m, nil + + case changeMsg: + return m.changed(msg) + + case fetchDoneMsg: + m.fetch.Done++ + if msg.err != nil { + m.fetch.Failed++ + } + if s, ok := m.byPath[msg.path]; ok { + s.FetchErr = msg.err + } + if m.fetch.Done == m.fetch.Total { + m.fetch.Running = false + m.fetch.At = time.Now() } return m, nil @@ -115,8 +214,9 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.spinner, cmd = m.spinner.Update(msg) m.tree.SetSpinner(m.spinner.View()) m.detail.SetSpinner(m.spinner.View()) - if m.repo != nil && m.loading == 0 { - return m, nil // stop ticking once everything is loaded + if !m.busy() { + m.ticking = false // stop ticking until there is work again + return m, nil } return m, cmd @@ -132,6 +232,81 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } +// treeLoaded installs a (re)loaded tree. On a reload the known statuses are +// carried over by path, so rows don't fall back to a spinner. +func (m Model) treeLoaded(msg treeLoadedMsg) (tea.Model, tea.Cmd) { + var cmds []tea.Cmd + if m.loads.done(treeKey) { + cmds = append(cmds, m.loadTree()) + } + initial := m.repo == nil + if msg.err != nil { + if initial { + m.err = msg.err + return m, nil + } + cmds = append(cmds, m.footer.Flash("reload failed: "+msg.err.Error())) + return m, tea.Batch(cmds...) + } + + old := m.byPath + m.repo = msg.repo + m.byPath = map[string]*submodule.Submodule{} + reload := initial || m.reloadStatuses + m.reloadStatuses = false + submodule.Walk(m.repo.Submodules, func(s *submodule.Submodule) { + m.byPath[s.Path] = s + if o, ok := old[s.Path]; ok { + s.Status, s.Err, s.FetchErr = o.Status, o.Err, o.FetchErr + } + if reload || s.Status == nil { + cmds = append(cmds, m.reloadStatus(s.Path)) + } + }) + + if initial { + m.tree.SetRoots(m.repo.Submodules) + if m.watcher != nil { + m.changes = m.watcher.Watch(m.ctx) + cmds = append(cmds, m.waitForChange()) + } + if m.opts.Fetch { + cmds = append(cmds, m.startFetch()) + } + } else { + m.tree.ReplaceRoots(m.repo.Submodules) + } + cmds = append(cmds, m.tick()) + return m, tea.Batch(cmds...) +} + +// changed reacts to a change the watcher saw, whoever made it. +func (m Model) changed(msg changeMsg) (tea.Model, tea.Cmd) { + if msg.closed { + return m, nil + } + cmds := []tea.Cmd{m.waitForChange()} + c := msg.change + switch { + case c.Path == "" || c.Structure: + // superproject: pins of all submodules may have moved (local), or + // only its header (remote); .gitmodules: the structure changed + cmds = append(cmds, m.reloadTree(c.Local)) + default: + s, ok := m.byPath[c.Path] + if !ok { + break + } + cmds = append(cmds, m.reloadStatus(s.Path)) + if c.Local { // the children's pins live in this repository + for _, child := range s.Children { + cmds = append(cmds, m.reloadStatus(child.Path)) + } + } + } + return m, tea.Batch(cmds...) +} + func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { k := m.keys switch { @@ -162,7 +337,23 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.tree.PageUp() case key.Matches(msg, k.PageDown): m.tree.PageDown() - case key.Matches(msg, k.Update, k.Bump, k.Fetch, k.Filter, k.Issues): + case key.Matches(msg, k.Fetch): + var flash string + switch { + case m.fetcher == nil: + flash = "this data source can't fetch" + case m.fetch.Running: + flash = "already fetching" + } + if flash != "" { + cmd := m.footer.Flash(flash) + m.layout() + return m, cmd + } + return m, m.startFetch() + case key.Matches(msg, k.Reload): + return m, m.reloadTree(true) + case key.Matches(msg, k.Update, k.Bump, k.Filter, k.Issues): cmd := m.footer.Flash("“" + msg.String() + "” is not implemented in the prototype") m.layout() return m, cmd @@ -204,7 +395,7 @@ func (m Model) View() string { } return zone.Scan(lipgloss.JoinVertical(lipgloss.Left, - header.View(m.theme, m.repo, m.width), + header.View(m.theme, m.repo, m.fetch, m.spinner.View(), m.width), body, m.footer.View(), )) diff --git a/internal/tui/detail/detail.go b/internal/tui/detail/detail.go index 5e08c68..ae44621 100644 --- a/internal/tui/detail/detail.go +++ b/internal/tui/detail/detail.go @@ -99,6 +99,12 @@ func (m Model) lines(s *submodule.Submodule) []string { label("")+muted.Render("via "+st.Pin.Source.String()), ) } + if st.Presence == submodule.PresenceOK { + out = append(out, label("Fetched")+muted.Render(theme.Ago(st.FetchedAt))) + } + if s.FetchErr != nil { + out = append(out, theme.Fg(t.Warn).Width(labelWidth).Render("fetch")+text.Render("failed: "+s.FetchErr.Error())) + } // commits commitLine := func(name string, c *submodule.Commit, highlight bool) string { diff --git a/internal/tui/header/header.go b/internal/tui/header/header.go index ee112b2..21f5e58 100644 --- a/internal/tui/header/header.go +++ b/internal/tui/header/header.go @@ -3,6 +3,7 @@ package header import ( "fmt" + "time" "github.com/charmbracelet/lipgloss" @@ -10,8 +11,18 @@ import ( "submarine/internal/tui/theme" ) -// View renders repo info, state counts and fetch age on one line. -func View(t theme.Theme, repo *submodule.Repo, width int) string { +// Fetch is the progress of the application's own background fetch. +type Fetch struct { + Running bool + Done int // repositories fetched (or failed) + Total int // repositories to fetch + Failed int // failed fetches of the last run + At time.Time // when the last run finished +} + +// View renders repo info, state counts and fetch state on one line. spinner +// is the current spinner frame. +func View(t theme.Theme, repo *submodule.Repo, fetch Fetch, spinner string, width int) string { bar := lipgloss.NewStyle().Background(t.Bar) seg := func(c lipgloss.TerminalColor, s string) string { return bar.Foreground(c).Render(s) } @@ -60,7 +71,22 @@ func View(t theme.Theme, repo *submodule.Repo, width int) string { mid += sep + seg(t.Muted, fmt.Sprintf("%d loading", loading)) } - right := seg(t.Muted, "fetched "+theme.Ago(repo.FetchedAt)+" ") + var right string + switch { + case fetch.Running: + right = seg(t.Primary, spinner) + seg(t.Muted, fmt.Sprintf(" fetching %d/%d ", fetch.Done, fetch.Total)) + default: + // our own fetch may have finished before the watcher saw FETCH_HEAD + at := repo.FetchedAt + if fetch.At.After(at) { + at = fetch.At + } + right = seg(t.Muted, "fetched "+theme.Ago(at)) + if fetch.Failed > 0 { + right += seg(t.Muted, " · ") + seg(t.Warn, fmt.Sprintf("%d failed", fetch.Failed)) + } + right += seg(t.Muted, " ") + } gap := width - lipgloss.Width(left) - lipgloss.Width(mid) - lipgloss.Width(right) if gap < 4 { diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 4fed449..3d0382f 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -3,11 +3,11 @@ package tui import "github.com/charmbracelet/bubbles/key" type keyMap struct { - Up, Down, Left, Right, Toggle key.Binding - CollapseAll, ExpandAll key.Binding - Home, End, PageUp, PageDown key.Binding - Update, Bump, Fetch, Filter, Issues key.Binding - Help, Quit key.Binding + Up, Down, Left, Right, Toggle key.Binding + CollapseAll, ExpandAll key.Binding + Home, End, PageUp, PageDown key.Binding + Update, Bump, Fetch, Reload, Filter, Issues key.Binding + Help, Quit key.Binding } func defaultKeys() keyMap { @@ -26,6 +26,7 @@ func defaultKeys() keyMap { Update: key.NewBinding(key.WithKeys("u"), key.WithHelp("u", "checkout pin")), Bump: key.NewBinding(key.WithKeys("b"), key.WithHelp("b", "bump pin to tip")), Fetch: key.NewBinding(key.WithKeys("f"), key.WithHelp("f", "fetch")), + Reload: key.NewBinding(key.WithKeys("r"), key.WithHelp("r", "reload")), Filter: key.NewBinding(key.WithKeys("/"), key.WithHelp("/", "filter")), Issues: key.NewBinding(key.WithKeys("!"), key.WithHelp("!", "problems only")), Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")), @@ -42,7 +43,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.Update, k.Bump, k.Fetch, k.Filter, k.Issues}, + {k.Update, k.Bump, k.Fetch, k.Reload, k.Filter, k.Issues}, {k.Help, k.Quit}, } } diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go index 0c2de2c..cc2a3f0 100644 --- a/internal/tui/msgs.go +++ b/internal/tui/msgs.go @@ -12,3 +12,17 @@ type statusLoadedMsg struct { status *submodule.Status err error } + +// fetchDoneMsg reports one finished fetch. It only updates the fetch +// progress; refreshing is left to the watcher, which also sees fetches made +// outside the application. +type fetchDoneMsg struct { + path string // "" is the superproject + err error +} + +// changeMsg is a change reported by the provider's watcher. +type changeMsg struct { + change submodule.Change + closed bool // the watcher stopped +} diff --git a/internal/tui/refresh.go b/internal/tui/refresh.go new file mode 100644 index 0000000..60c2190 --- /dev/null +++ b/internal/tui/refresh.go @@ -0,0 +1,43 @@ +package tui + +// treeKey is the reloader key for loading the tree; submodules use their +// path. +const treeKey = "\x00tree" + +// reloader allows at most one load per key at a time. A request while a +// load is running marks the key dirty, and the load runs once more after +// the current one finishes, so the last result always reflects the latest +// change and results never arrive out of order. +type reloader struct { + running map[string]bool + dirty map[string]bool +} + +func newReloader() reloader { + return reloader{running: map[string]bool{}, dirty: map[string]bool{}} +} + +// start reports whether a load for key should start now. If one is already +// running, the key is marked dirty instead. +func (r reloader) start(key string) bool { + if r.running[key] { + r.dirty[key] = true + return false + } + r.running[key] = true + return true +} + +// done marks the load for key as finished. It reports whether the load must +// run again because it was requested meanwhile; the key then stays running. +func (r reloader) done(key string) (again bool) { + if r.dirty[key] { + delete(r.dirty, key) + return true + } + delete(r.running, key) + return false +} + +// busy reports whether any load is running. +func (r reloader) busy() bool { return len(r.running) > 0 } diff --git a/internal/tui/refresh_test.go b/internal/tui/refresh_test.go new file mode 100644 index 0000000..3e113cf --- /dev/null +++ b/internal/tui/refresh_test.go @@ -0,0 +1,31 @@ +package tui + +import "testing" + +func TestReloader(t *testing.T) { + r := newReloader() + if !r.start("a") { + t.Fatal("first start: want true") + } + if r.start("a") || r.start("a") { + t.Fatal("start while running: want false") + } + if !r.start("b") { + t.Fatal("other key: want true") + } + if !r.done("a") { + t.Fatal("done after requests while running: want again") + } + if r.done("a") { + t.Fatal("second done: requests were coalesced into one rerun") + } + if r.done("b") { + t.Fatal("done without requests: want no rerun") + } + if r.busy() { + t.Fatal("busy after all done") + } + if !r.start("a") { + t.Fatal("start after done: want true") + } +} diff --git a/internal/tui/tree/tree.go b/internal/tui/tree/tree.go index 05b6023..92b2ded 100644 --- a/internal/tui/tree/tree.go +++ b/internal/tui/tree/tree.go @@ -47,6 +47,28 @@ func (m *Model) SetRoots(roots []*submodule.Submodule) { } } +// ReplaceRoots swaps in a reloaded tree. Unlike SetRoots it keeps the +// expansion state and the cursor (by path); if the selected submodule is +// gone, the cursor moves to the first one. +func (m *Model) ReplaceRoots(roots []*submodule.Submodule) { + var path string + if m.cursor != nil { + path = m.cursor.Path + } + m.roots = roots + m.cursor = nil + for _, r := range m.rows() { + if r.kind == rowNode && r.node.Path == path { + m.cursor = r.node + break + } + } + if m.cursor == nil && len(roots) > 0 { + m.cursor = m.rows()[m.firstNodeRow()].node + } + m.clampOffset() +} + func (m *Model) SetSize(w, h int) { m.width, m.height = w, h m.clampOffset() diff --git a/internal/tui/tree/tree_test.go b/internal/tui/tree/tree_test.go index 36cdd6c..7ce9010 100644 --- a/internal/tui/tree/tree_test.go +++ b/internal/tui/tree/tree_test.go @@ -69,6 +69,23 @@ func visible(m *Model) []string { return out } +func TestReplaceRootsKeepsState(t *testing.T) { + m := newTree(t) + m.Down() // b + m.Right() // expand b, cursor on c + m.Right() // expand c, cursor on d + assertCursor(t, m, "d") + + m.ReplaceRoots(fixture()) // same paths, new nodes + assertCursor(t, m, "d") + if got := len(visible(m)); got != 8 { + t.Errorf("visible rows = %v, want b and c still expanded", visible(m)) + } + + m.ReplaceRoots(fixture()[:1]) // only "a" left + assertCursor(t, m, "a") +} + func TestInitiallyCollapsedWithHeaders(t *testing.T) { m := newTree(t) got := visible(m)