From 4ac29c04d038550176b56338067aee543157e70c Mon Sep 17 00:00:00 2001 From: Simon Franken Date: Fri, 23 Oct 2026 18:43:17 +0200 Subject: [PATCH] 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 --- README.md | 2 +- cmd/submarine/main.go | 56 ++++++++++++------------ docs/architecture.md | 10 +++-- docs/decisions.md | 20 ++++++++- docs/requirements.md | 6 +++ docs/ux-guide.md | 11 +++-- internal/tui/app.go | 43 ++++++++++++++++++- internal/tui/footer/footer.go | 14 +++++- internal/tui/footer/footer_test.go | 54 ++++++++++++++++++++++++ internal/tui/keys.go | 33 +++++++++++++-- internal/tui/msgs.go | 4 ++ internal/tui/theme/help.go | 11 ++++- internal/tui/update_test.go | 68 ++++++++++++++++++++++++++++++ internal/update/update.go | 17 -------- 14 files changed, 288 insertions(+), 61 deletions(-) create mode 100644 internal/tui/footer/footer_test.go create mode 100644 internal/tui/update_test.go diff --git a/README.md b/README.md index bb7f2a0..4d0cdd2 100644 --- a/README.md +++ b/README.md @@ -157,7 +157,7 @@ files. | `x` | Restore: make the worktree exactly what git records (asks if work would be lost) | | `f` / `r` | Fetch / reload | | `?` | Full help | -| `q` | Quit | +| `q` | Quit. If a newer release exists, the footer offers `q quit and update to vX` (highlighted) and plain quit moves to `Q` | The mouse works too: click to select, click `▸` to toggle, wheel to move. In the branch popup, click a branch to switch to it, use the wheel to move, and diff --git a/cmd/submarine/main.go b/cmd/submarine/main.go index c20d7dd..c842f6c 100644 --- a/cmd/submarine/main.go +++ b/cmd/submarine/main.go @@ -6,7 +6,6 @@ import ( "flag" "fmt" "os" - "path/filepath" "time" tea "github.com/charmbracelet/bubbletea" @@ -71,42 +70,38 @@ func main() { zone.NewGlobal() defer zone.Close() - notice := checkForUpdate(ctx, *useMock) + var opts []tui.Option + if !*useMock && update.Release(version) && os.Getenv("SUBMARINE_NO_UPDATE_CHECK") == "" { + opts = append(opts, tui.WithUpdateCheck(latestRelease)) + } - p := tea.NewProgram(tui.New(ctx, provider), tea.WithAltScreen(), tea.WithMouseCellMotion()) - if _, err := p.Run(); err != nil { + p := tea.NewProgram(tui.New(ctx, provider, opts...), tea.WithAltScreen(), tea.WithMouseCellMotion()) + final, err := p.Run() + if err != nil { fmt.Fprintln(os.Stderr, err) os.Exit(1) } - select { - case v := <-notice: - if v != "" { - fmt.Fprintf(os.Stderr, "submarine %s is available (you have %s), run: submarine update\n", v, version) + // The user chose "quit and update": the terminal is restored by now. + if m, ok := final.(tui.Model); ok && m.UpdateTo() != "" { + fmt.Printf("updating to %s…\n", m.UpdateTo()) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Minute) + defer cancel() + if code := install(ctx, update.New(), m.UpdateTo()); code != 0 { + os.Exit(code) } - default: } } -// checkForUpdate looks for a newer release in the background (at most once a -// day, cached) and delivers its version, or "" when there is none. Disabled -// for dev builds, --mock and SUBMARINE_NO_UPDATE_CHECK. -func checkForUpdate(ctx context.Context, off bool) <-chan string { - out := make(chan string, 1) - cache, err := os.UserCacheDir() - if off || err != nil || !update.Release(version) || os.Getenv("SUBMARINE_NO_UPDATE_CHECK") != "" { - out <- "" - return out +// latestRelease returns the version of a newer release, or "" if there is +// none or the registry can't be reached within a few seconds. +func latestRelease(ctx context.Context) string { + ctx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + v, err := update.New().Latest(ctx) + if err != nil || !update.Newer(version, v) { + return "" } - go func() { - ctx, cancel := context.WithTimeout(ctx, 3*time.Second) - defer cancel() - v, err := update.New().LatestCached(ctx, filepath.Join(cache, "submarine", "latest-version")) - if err != nil || !update.Newer(version, v) { - v = "" - } - out <- v - }() - return out + return v } func runUpdate(args []string) int { @@ -135,6 +130,11 @@ func runUpdate(args []string) int { fmt.Fprintf(os.Stderr, "submarine: this is a %q build; use --force to replace it with %s\n", version, latest) return 1 } + return install(ctx, c, latest) +} + +// install replaces the running binary with release latest. +func install(ctx context.Context, c *update.Client, latest string) int { exe, err := c.Install(ctx, latest) if err != nil { fmt.Fprintln(os.Stderr, "submarine: update failed:", err) diff --git a/docs/architecture.md b/docs/architecture.md index 55892b4..ddd95ba 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -221,10 +221,12 @@ No CI runner: releases are built and published from a developer machine. `VERSION`, downloads the asset for the running platform, verifies the checksum and atomically replaces the real executable (symlinks resolved, so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry. -- After the TUI exits, a one-line notice is printed if a newer release - exists. The check runs in the background at startup, at most once a day - (cached in the user cache dir) and is off for dev builds, - `--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`. +- On every start the TUI looks for a newer release in the background (3 s + timeout, no cache, no blocking). `main` passes `tui.WithUpdateCheck` and + the TUI gets the result as a message; the footer then offers `q quit and + update to vX`. That key quits, and `main` installs the release once the + terminal is restored (the same code as `submarine update`). The check is off + for dev builds, `--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`. ## Testing diff --git a/docs/decisions.md b/docs/decisions.md index 215894a..fc812d4 100644 --- a/docs/decisions.md +++ b/docs/decisions.md @@ -200,7 +200,8 @@ package so versions stay clean. **Consequences.** Integrity is a sha256 from the same host: it catches corrupt or partial downloads, not a compromised registry (no signatures). The update check is a deliberate, outside-the-provider network use: only -on `update` and a once-a-day background check that can be disabled. +on `update` and a background check at startup that can be disabled (it +was once a day; see D27). ## Copy path with `c` via OSC 52 @@ -481,3 +482,20 @@ and the name is what people know. Long names are cut at 16 columns, so the label column cannot grow without bound. In a label every role word has its own style, and `you` is bold in the default color: it was lost between the muted words, and it is the one the user looks for. + +## D27 — Update check on every start, offered as "quit and update" + +**Decision.** The registry is asked on every start, in the background, with +no cache. A newer release shows up in the footer as `q quit and update to +vX`, right of help and quit, on a highlighted background (black on cyan). +While it is shown, `q` quits and updates, and plain quit is `Q`. `ctrl+c` +always quits without updating. The update runs after the TUI has exited. +**Rationale.** A once-a-day check with a notice printed after exit was +easy to miss. The hint is only useful where the user is looking, and the +key they use to leave is the natural place. Running the install after the +TUI exits keeps the terminal clean and reuses `submarine update`'s code. +**Consequences.** Every start makes one small request to the registry (3 s +timeout, failures are silent), which supersedes the once-a-day rule of D21. +N1 still holds: the TUI never waits for it. `q` changes meaning while an +update is available, so muscle memory that exits with `q` now updates; the +bar says so. Typing `q` in the filter or a popup is unchanged. diff --git a/docs/requirements.md b/docs/requirements.md index 68219ff..eb9d662 100644 --- a/docs/requirements.md +++ b/docs/requirements.md @@ -164,6 +164,12 @@ submarine [dir] # the repository containing dir (default: current directory) submarine --mock # built-in mock data ``` +- **F58** On every start the tool looks for a newer release in the + background (never blocking, silent on failure; off for dev builds, + `--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`). If there is one, the footer + shows `q quit and update to vX` right of help and quit, highlighted; `q` + then quits and installs the release, and plain quit is `Q`. + ## Non-functional requirements - **N1** Startup must not block on the network. The tool shows the local diff --git a/docs/ux-guide.md b/docs/ux-guide.md index ca02b68..28e6dd4 100644 --- a/docs/ux-guide.md +++ b/docs/ux-guide.md @@ -42,7 +42,10 @@ Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`): - **Navigation** (move, fold, toggle): left in the footer. -- **Global** (`?`, `q`): right-aligned in the footer, always kept. +- **Global** (`?`, `q`): right-aligned in the footer, always kept. If a newer + release exists, `q quit and update to vX` follows them as the right-most + item, on a highlighted background (`Theme.Highlight`: black on Primary), and + plain quit is `Q` (see D27). - **Submodule** (act on the selected one, e.g. `c`): a palette on the last line of the detail pane. It is left out if the pane is shorter than 12 lines. Whenever the palette isn't shown (pane hidden or too short), these @@ -250,6 +253,7 @@ colors in components. | Bar | adaptive 254 (light) / 235 (dark) | header background | | Selected | adaptive 253 (light) / 237 (dark) | selected row background | | Backdrop | adaptive 252 (light) / 238 (dark) | the screen behind a popup | +| Highlight | ANSI `0` black, bold, on Primary | the `quit and update` hint in the footer | - Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`. The background is detected once in `main` before Bubble Tea starts. @@ -283,7 +287,7 @@ colors in components. | `home` / `g`, `end` / `G` | Header (superproject) / last submodule | | `pgup`, `pgdn` | Move by one screen | | `?` | Toggle full help | -| `q`, `ctrl+c` | Quit | +| `q`, `ctrl+c` | Quit. With a newer release: `q` quits and updates, `Q` and `ctrl+c` quit | | `f` | Fetch all repositories (does nothing while a fetch runs) | | `r` | Reload everything (e.g. after editing files) | | `c` | Copy the absolute directory of the selected submodule to the clipboard (OSC 52, so it also works over SSH; needs terminal support, in tmux `set -g set-clipboard on`). The footer confirms with `copied `. Without a local provider (mock) the path relative to the superproject is copied | @@ -413,7 +417,8 @@ where the palette can't be shown, see [Key bar and palette](#key-bar-and-palette | `/` | Filter | Global | Help | | `!` | Problems only (toggle) | Global | Help | | `?` | Toggle full help | Global | Bar, Help | -| `q`, `ctrl+c` | Quit | Global | Bar, Help | +| `q`, `ctrl+c` | Quit (`Q`, `ctrl+c` while an update is offered) | Global | Bar, Help | +| `q` | Quit and update to the newer release (only if there is one) | Global | Bar (highlighted), Help | ### Mouse diff --git a/internal/tui/app.go b/internal/tui/app.go index 8eeb5b7..4a06a4b 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -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() diff --git a/internal/tui/footer/footer.go b/internal/tui/footer/footer.go index ab8812c..11a816c 100644 --- a/internal/tui/footer/footer.go +++ b/internal/tui/footer/footer.go @@ -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()...) diff --git a/internal/tui/footer/footer_test.go b/internal/tui/footer/footer_test.go new file mode 100644 index 0000000..117bc12 --- /dev/null +++ b/internal/tui/footer/footer_test.go @@ -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) + } +} diff --git a/internal/tui/keys.go b/internal/tui/keys.go index 68cee84..3b44793 100644 --- a/internal/tui/keys.go +++ b/internal/tui/keys.go @@ -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()...), } } diff --git a/internal/tui/msgs.go b/internal/tui/msgs.go index cc2a3f0..1daa492 100644 --- a/internal/tui/msgs.go +++ b/internal/tui/msgs.go @@ -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 } diff --git a/internal/tui/theme/help.go b/internal/tui/theme/help.go index e4d1483..cd0bdef 100644 --- a/internal/tui/theme/help.go +++ b/internal/tui/theme/help.go @@ -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) +} diff --git a/internal/tui/update_test.go b/internal/tui/update_test.go new file mode 100644 index 0000000..51ae587 --- /dev/null +++ b/internal/tui/update_test.go @@ -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()) + } +} diff --git a/internal/update/update.go b/internal/update/update.go index 8143084..838e543 100644 --- a/internal/update/update.go +++ b/internal/update/update.go @@ -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 -}