Offer "quit and update" in the footer, check for updates on every start

The update check ran at most once a day and only printed a line after the
TUI exited. Now the registry is asked on every start (in the background,
3 s timeout, no cache) and the result reaches the TUI as a message.

With a newer release, the footer shows `q quit and update to vX` right of
help and quit, on a highlighted background (Theme.Highlight). `q` quits
and `main` installs the release once the terminal is restored, sharing
the code of `submarine update`; plain quit moves to `Q`, ctrl+c never
updates.

Drop LatestCached and CheckInterval. Add D27 and F58.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
2026-10-23 18:43:17 +02:00
co-authored by Claude Sonnet 5.5
parent e8d366d88f
commit 4ac29c04d0
14 changed files with 288 additions and 61 deletions
+41 -2
View File
@@ -61,11 +61,28 @@ type Model struct {
pickerPath string // the repository the picker is for
notice string // an error to show above the footer until the next key
checkUpdate func(context.Context) string // looks for a newer release, nil if off
update string // the newer release found, "" if none
updateTo string // the release to quit and update to, "" if not chosen
width, height int
showDetail bool
}
func New(ctx context.Context, p submodule.Provider) Model {
// Option configures New.
type Option func(*Model)
// WithUpdateCheck looks for a newer release at startup, in the background.
// check returns its version, or "" if there is none. With one, the footer
// offers to quit and update.
func WithUpdateCheck(check func(context.Context) string) Option {
return func(m *Model) { m.checkUpdate = check }
}
// UpdateTo returns the release the user chose to quit and update to, or "".
func (m Model) UpdateTo() string { return m.updateTo }
func New(ctx context.Context, p submodule.Provider, opts ...Option) Model {
t := theme.Default()
k := defaultKeys()
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
@@ -90,12 +107,22 @@ func New(ctx context.Context, p submodule.Provider) Model {
}
m.fetcher, _ = p.(submodule.Fetcher)
m.watcher, _ = p.(submodule.Watcher)
for _, o := range opts {
o(&m)
}
return m
}
func (m Model) Init() tea.Cmd {
m.loads.start(treeKey)
return tea.Batch(m.loadTree(), m.spinner.Tick)
return tea.Batch(m.loadTree(), m.spinner.Tick, m.lookForUpdate())
}
func (m Model) lookForUpdate() tea.Cmd {
if m.checkUpdate == nil {
return nil
}
return func() tea.Msg { return updateMsg{m.checkUpdate(m.ctx)} }
}
func (m Model) loadTree() tea.Cmd {
@@ -187,6 +214,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.layout()
return m, nil
case updateMsg:
if msg.version != "" {
m.update = msg.version
m.keys.SetUpdate(msg.version)
m.footer.SetKeys(m.keys)
m.layout()
}
return m, nil
case treeLoadedMsg:
return m.treeLoaded(msg)
@@ -361,6 +397,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
m.closeFilter()
case key.Matches(msg, k.Quit):
return m, tea.Quit
case key.Matches(msg, k.QuitUpdate):
m.updateTo = m.update
return m, tea.Quit
case key.Matches(msg, k.Help):
m.footer.ToggleHelp()
m.layout()
+13 -1
View File
@@ -19,13 +19,15 @@ const flashDuration = 2 * time.Second
type clearFlashMsg struct{ id int }
// Keys provides the key groups. The short bar shows Navigation (and
// Submodule, if requested) on the left and Global on the right; full help
// Submodule, if requested) on the left, Global and then Highlight (on a
// highlighted background) on the right; full help
// shows FullHelp.
type Keys interface {
help.KeyMap
Navigation() []key.Binding
Submodule() []key.Binding
Global() []key.Binding
Highlight() []key.Binding
}
type Model struct {
@@ -42,6 +44,9 @@ func New(t theme.Theme, keys Keys) Model {
return Model{theme: t, help: theme.NewHelp(t), keys: keys}
}
// SetKeys replaces the key groups, e.g. if bindings changed.
func (m *Model) SetKeys(k Keys) { m.keys = k }
// SetActions shows the submodule bindings in the short bar. Set it if the
// detail pane doesn't show them.
func (m *Model) SetActions(on bool) { m.actions = on }
@@ -85,6 +90,13 @@ func (m Model) shortBar(w int) string {
h := m.help
h.Width = 0
right := h.ShortHelpView(m.keys.Global())
if hl := m.keys.Highlight(); len(hl) > 0 {
var parts []string
for _, b := range hl {
parts = append(parts, b.Help().Key+" "+b.Help().Desc)
}
right += " " + m.theme.Highlight().Render(" "+strings.Join(parts, " ")+" ")
}
left := m.keys.Navigation()
if m.actions {
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
+54
View File
@@ -0,0 +1,54 @@
package footer
import (
"strings"
"testing"
"github.com/charmbracelet/bubbles/key"
"github.com/charmbracelet/lipgloss"
"github.com/charmbracelet/x/ansi"
"submarine/internal/tui/theme"
)
type testKeys struct{ highlight []key.Binding }
func bind(k, desc string) key.Binding { return key.NewBinding(key.WithKeys(k), key.WithHelp(k, desc)) }
func (testKeys) ShortHelp() []key.Binding { return nil }
func (testKeys) FullHelp() [][]key.Binding { return nil }
func (testKeys) Navigation() []key.Binding {
return []key.Binding{bind("j", "move"), bind("h", "fold")}
}
func (testKeys) Submodule() []key.Binding { return nil }
func (testKeys) Global() []key.Binding { return []key.Binding{bind("?", "help"), bind("Q", "quit")} }
func (k testKeys) Highlight() []key.Binding { return k.highlight }
func bar(w int, hl ...key.Binding) string {
m := New(theme.Default(), testKeys{hl})
m.SetWidth(w)
return ansi.Strip(m.View())
}
func TestUpdateIsRightmost(t *testing.T) {
got := strings.TrimRight(bar(80, bind("q", "quit and update to v1.2.3")), " ")
if !strings.HasSuffix(got, "? help • Q quit q quit and update to v1.2.3") {
t.Errorf("order must be help, quit, quit and update:\n%q", got)
}
if w := lipgloss.Width(bar(80, bind("q", "quit and update to v1.2.3"))); w != 80 {
t.Errorf("bar is %d wide, want 80", w)
}
}
func TestNoUpdate(t *testing.T) {
if got := bar(80); strings.Contains(got, "update") {
t.Errorf("unexpected update hint:\n%q", got)
}
}
func TestNavigationGivesWayToUpdate(t *testing.T) {
got := bar(50, bind("q", "quit and update to v1.2.3"))
if !strings.Contains(got, "q quit and update to v1.2.3") || !strings.Contains(got, "Q quit") {
t.Errorf("the right group must be kept:\n%q", got)
}
}
+30 -3
View File
@@ -1,6 +1,8 @@
package tui
import "github.com/charmbracelet/bubbles/key"
import (
"github.com/charmbracelet/bubbles/key"
)
type keyMap struct {
Up, Down, Left, Right, Toggle key.Binding
@@ -8,7 +10,7 @@ type keyMap struct {
Home, End, PageUp, PageDown key.Binding
Fetch, Reload, Filter, Issues key.Binding
Copy, Restore, Switch key.Binding
Help, Quit key.Binding
Help, Quit, QuitUpdate key.Binding
}
func defaultKeys() keyMap {
@@ -33,9 +35,17 @@ func defaultKeys() keyMap {
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
QuitUpdate: key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit and update"), key.WithDisabled()),
}
}
// SetUpdate announces the newer release v: q quits and updates to it, and the
// plain quit moves to Q (ctrl+c always quits without updating).
func (k *keyMap) SetUpdate(v string) {
k.Quit = key.NewBinding(key.WithKeys("Q", "ctrl+c"), key.WithHelp("Q", "quit"))
k.QuitUpdate = key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit and update to "+v))
}
// Navigation returns the bindings that move the selection or fold the tree.
func (k keyMap) Navigation() []key.Binding {
move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move"))
@@ -51,6 +61,23 @@ func (k keyMap) Submodule() []key.Binding { return []key.Binding{k.Copy, k.Switc
// Global returns the bindings that don't depend on the selection.
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
// fullUpdate is Highlight with a short description, for the full help.
func (k keyMap) fullUpdate() []key.Binding {
if !k.QuitUpdate.Enabled() {
return nil
}
return []key.Binding{key.NewBinding(key.WithKeys("q"), key.WithHelp("q", "quit and update"))}
}
// Highlight returns the bindings that are shown after Global on a highlighted
// background: quit and update, if there is an update.
func (k keyMap) Highlight() []key.Binding {
if !k.QuitUpdate.Enabled() {
return nil
}
return []key.Binding{k.QuitUpdate}
}
func (k keyMap) ShortHelp() []key.Binding {
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
}
@@ -62,6 +89,6 @@ func (k keyMap) FullHelp() [][]key.Binding {
{k.Home, k.End, k.PageUp, k.PageDown},
{k.Copy, k.Switch, k.Restore},
{k.Fetch, k.Reload, k.Filter, k.Issues},
{k.Help, k.Quit},
append([]key.Binding{k.Help, k.Quit}, k.fullUpdate()...),
}
}
+4
View File
@@ -26,3 +26,7 @@ type changeMsg struct {
change submodule.Change
closed bool // the watcher stopped
}
// updateMsg carries the result of the startup update check; version is ""
// if there is no newer release.
type updateMsg struct{ version string }
+10 -1
View File
@@ -1,6 +1,9 @@
package theme
import "github.com/charmbracelet/bubbles/help"
import (
"github.com/charmbracelet/bubbles/help"
"github.com/charmbracelet/lipgloss"
)
// NewHelp returns a help renderer in the theme's colors.
func NewHelp(t Theme) help.Model {
@@ -13,3 +16,9 @@ func NewHelp(t Theme) help.Model {
h.Styles.FullSeparator = h.Styles.ShortSeparator
return h
}
// Highlight is the style of a key hint that should catch the eye: dark text
// on the accent color, on the whole hint including its key.
func (t Theme) Highlight() lipgloss.Style {
return lipgloss.NewStyle().Foreground(lipgloss.Color("0")).Background(t.Primary).Bold(true)
}
+68
View File
@@ -0,0 +1,68 @@
package tui
import (
"context"
"strings"
"testing"
tea "github.com/charmbracelet/bubbletea"
"submarine/internal/submodule/mock"
)
func newUpdateModel(version string) tea.Model {
check := func(context.Context) string { return version }
var m tea.Model = New(context.Background(), mock.New(), WithUpdateCheck(check))
m, _ = m.Update(tea.WindowSizeMsg{Width: 120, Height: 30})
return m
}
func isQuit(cmd tea.Cmd) bool {
if cmd == nil {
return false
}
_, ok := cmd().(tea.QuitMsg)
return ok
}
func TestUpdateCheckDeliversMessage(t *testing.T) {
m := New(context.Background(), mock.New(), WithUpdateCheck(func(context.Context) string { return "v9.9.9" }))
if m.lookForUpdate() == nil {
t.Fatal("no update check was started")
}
if msg := m.lookForUpdate()(); msg != (updateMsg{"v9.9.9"}) {
t.Errorf("got %v", msg)
}
if New(context.Background(), mock.New()).lookForUpdate() != nil {
t.Error("an update check was started without the option")
}
}
func TestQuitWithoutUpdate(t *testing.T) {
m := newUpdateModel("")
m, _ = m.Update(updateMsg{""})
m, cmd := press(m, "q")
if !isQuit(cmd) || m.(Model).UpdateTo() != "" {
t.Errorf("q must quit without updating, got update to %q", m.(Model).UpdateTo())
}
}
func TestQuitAndUpdate(t *testing.T) {
m := newUpdateModel("v9.9.9")
m, _ = m.Update(updateMsg{"v9.9.9"})
if bar := m.(Model).footer.View(); !strings.Contains(bar, "q quit and update to v9.9.9") {
t.Errorf("the footer does not offer the update:\n%s", bar)
}
m2, cmd := press(m, "q")
if !isQuit(cmd) || m2.(Model).UpdateTo() != "v9.9.9" {
t.Errorf("q must quit and update, got update to %q", m2.(Model).UpdateTo())
}
m2, cmd = press(m, "Q")
if !isQuit(cmd) || m2.(Model).UpdateTo() != "" {
t.Errorf("Q must quit without updating, got update to %q", m2.(Model).UpdateTo())
}
m2, cmd = pressKey(m, tea.KeyCtrlC)
if !isQuit(cmd) || m2.(Model).UpdateTo() != "" {
t.Errorf("ctrl+c must quit without updating, got update to %q", m2.(Model).UpdateTo())
}
}
-17
View File
@@ -209,20 +209,3 @@ func (l *limitWriter) Write(p []byte) (int, error) {
}
return l.w.Write(p)
}
// CheckInterval is how often the passive check may hit the network.
const CheckInterval = 24 * time.Hour
// LatestCached is Latest with a file cache: within CheckInterval it returns
// the cached answer without any network access. A failed lookup is cached too,
// so an unreachable registry is not retried on every start.
func (c *Client) LatestCached(ctx context.Context, cacheFile string) (string, error) {
if fi, err := os.Stat(cacheFile); err == nil && time.Since(fi.ModTime()) < CheckInterval {
b, _ := os.ReadFile(cacheFile)
return strings.TrimSpace(string(b)), nil
}
v, err := c.Latest(ctx)
_ = os.MkdirAll(filepath.Dir(cacheFile), 0o755)
_ = os.WriteFile(cacheFile, []byte(v), 0o644) // v is "" on failure
return v, err
}