Archived
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:
+41
-2
@@ -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()
|
||||
|
||||
@@ -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()...)
|
||||
|
||||
@@ -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
@@ -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()...),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 }
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user