Fetch in the background and follow changes in the repositories

- On startup, after the local state is shown, fetch the superproject and
  every initialized submodule (all remotes, prune, at most 8 at once).
  Fetches never prompt; failures show git's message. --no-fetch skips it,
  f fetches again.
- The header shows "fetching n/m", then the fetch age and failures; the
  detail pane shows each submodule's last fetch and fetch errors.
- The git provider polls each repository's metadata (refs, HEAD, index,
  .gitmodules) every 2 s and reports changes. The app reloads only what
  changed, keeping old values, expansion and cursor; loads are
  single-flight with coalesced reruns. Refresh is driven by the
  repositories, so a fetch in another shell updates the view like ours.
- r reloads everything (worktree edits are not watched).
- Docs: F50-F53, N1/N8 rewritten, D18-D20 (D12 superseded).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Simon Franken
2026-10-21 16:15:22 +02:00
co-authored by Claude Opus 5.5
parent 19c6f17eb2
commit 27f0c0b5d2
27 changed files with 1097 additions and 90 deletions
+25 -7
View File
@@ -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
}
+79
View File
@@ -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
}
+161
View File
@@ -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)
}
}
+43
View File
@@ -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: <path>".
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)
}
+19 -4
View File
@@ -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 {
+12 -5
View File
@@ -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 == "" {
+123
View File
@@ -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
})
}
+50 -9
View File
@@ -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()
+4 -2
View File
@@ -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
}
+24
View File
@@ -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
}
+217 -26
View File
@@ -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(),
))
+6
View File
@@ -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 {
+29 -3
View File
@@ -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 {
+7 -6
View File
@@ -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},
}
}
+14
View File
@@ -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
}
+43
View File
@@ -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 }
+31
View File
@@ -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")
}
}
+22
View File
@@ -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()
+17
View File
@@ -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)