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
+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)