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:
@@ -157,7 +157,7 @@ files.
|
|||||||
| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) |
|
| `x` | Restore: make the worktree exactly what git records (asks if work would be lost) |
|
||||||
| `f` / `r` | Fetch / reload |
|
| `f` / `r` | Fetch / reload |
|
||||||
| `?` | Full help |
|
| `?` | 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 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
|
the branch popup, click a branch to switch to it, use the wheel to move, and
|
||||||
|
|||||||
+28
-28
@@ -6,7 +6,6 @@ import (
|
|||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
tea "github.com/charmbracelet/bubbletea"
|
tea "github.com/charmbracelet/bubbletea"
|
||||||
@@ -71,42 +70,38 @@ func main() {
|
|||||||
zone.NewGlobal()
|
zone.NewGlobal()
|
||||||
defer zone.Close()
|
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())
|
p := tea.NewProgram(tui.New(ctx, provider, opts...), tea.WithAltScreen(), tea.WithMouseCellMotion())
|
||||||
if _, err := p.Run(); err != nil {
|
final, err := p.Run()
|
||||||
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, err)
|
fmt.Fprintln(os.Stderr, err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
}
|
}
|
||||||
select {
|
// The user chose "quit and update": the terminal is restored by now.
|
||||||
case v := <-notice:
|
if m, ok := final.(tui.Model); ok && m.UpdateTo() != "" {
|
||||||
if v != "" {
|
fmt.Printf("updating to %s…\n", m.UpdateTo())
|
||||||
fmt.Fprintf(os.Stderr, "submarine %s is available (you have %s), run: submarine update\n", v, version)
|
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
|
// latestRelease returns the version of a newer release, or "" if there is
|
||||||
// day, cached) and delivers its version, or "" when there is none. Disabled
|
// none or the registry can't be reached within a few seconds.
|
||||||
// for dev builds, --mock and SUBMARINE_NO_UPDATE_CHECK.
|
func latestRelease(ctx context.Context) string {
|
||||||
func checkForUpdate(ctx context.Context, off bool) <-chan string {
|
ctx, cancel := context.WithTimeout(ctx, 3*time.Second)
|
||||||
out := make(chan string, 1)
|
defer cancel()
|
||||||
cache, err := os.UserCacheDir()
|
v, err := update.New().Latest(ctx)
|
||||||
if off || err != nil || !update.Release(version) || os.Getenv("SUBMARINE_NO_UPDATE_CHECK") != "" {
|
if err != nil || !update.Newer(version, v) {
|
||||||
out <- ""
|
return ""
|
||||||
return out
|
|
||||||
}
|
}
|
||||||
go func() {
|
return v
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func runUpdate(args []string) int {
|
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)
|
fmt.Fprintf(os.Stderr, "submarine: this is a %q build; use --force to replace it with %s\n", version, latest)
|
||||||
return 1
|
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)
|
exe, err := c.Install(ctx, latest)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
fmt.Fprintln(os.Stderr, "submarine: update failed:", err)
|
fmt.Fprintln(os.Stderr, "submarine: update failed:", err)
|
||||||
|
|||||||
@@ -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
|
`VERSION`, downloads the asset for the running platform, verifies the
|
||||||
checksum and atomically replaces the real executable (symlinks resolved,
|
checksum and atomically replaces the real executable (symlinks resolved,
|
||||||
so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry.
|
so `sub` works too). `SUBMARINE_UPDATE_URL` overrides the registry.
|
||||||
- After the TUI exits, a one-line notice is printed if a newer release
|
- On every start the TUI looks for a newer release in the background (3 s
|
||||||
exists. The check runs in the background at startup, at most once a day
|
timeout, no cache, no blocking). `main` passes `tui.WithUpdateCheck` and
|
||||||
(cached in the user cache dir) and is off for dev builds,
|
the TUI gets the result as a message; the footer then offers `q quit and
|
||||||
`--mock` and `SUBMARINE_NO_UPDATE_CHECK=1`.
|
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
|
## Testing
|
||||||
|
|
||||||
|
|||||||
+19
-1
@@ -200,7 +200,8 @@ package so versions stay clean.
|
|||||||
**Consequences.** Integrity is a sha256 from the same host: it catches
|
**Consequences.** Integrity is a sha256 from the same host: it catches
|
||||||
corrupt or partial downloads, not a compromised registry (no signatures).
|
corrupt or partial downloads, not a compromised registry (no signatures).
|
||||||
The update check is a deliberate, outside-the-provider network use: only
|
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
|
## 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
|
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
|
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.
|
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.
|
||||||
|
|||||||
@@ -164,6 +164,12 @@ submarine [dir] # the repository containing dir (default: current directory)
|
|||||||
submarine --mock # built-in mock data
|
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
|
## Non-functional requirements
|
||||||
|
|
||||||
- **N1** Startup must not block on the network. The tool shows the local
|
- **N1** Startup must not block on the network. The tool shows the local
|
||||||
|
|||||||
+8
-3
@@ -42,7 +42,10 @@
|
|||||||
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
|
Keys come in three groups (`keyMap.Navigation`, `.Submodule`, `.Global`):
|
||||||
|
|
||||||
- **Navigation** (move, fold, toggle): left in the footer.
|
- **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
|
- **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
|
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
|
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 |
|
| Bar | adaptive 254 (light) / 235 (dark) | header background |
|
||||||
| Selected | adaptive 253 (light) / 237 (dark) | selected row background |
|
| Selected | adaptive 253 (light) / 237 (dark) | selected row background |
|
||||||
| Backdrop | adaptive 252 (light) / 238 (dark) | the screen behind a popup |
|
| 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`.
|
- Background shades have no ANSI-16 equivalent, so they use `AdaptiveColor`.
|
||||||
The background is detected once in `main` before Bubble Tea starts.
|
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 |
|
| `home` / `g`, `end` / `G` | Header (superproject) / last submodule |
|
||||||
| `pgup`, `pgdn` | Move by one screen |
|
| `pgup`, `pgdn` | Move by one screen |
|
||||||
| `?` | Toggle full help |
|
| `?` | 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) |
|
| `f` | Fetch all repositories (does nothing while a fetch runs) |
|
||||||
| `r` | Reload everything (e.g. after editing files) |
|
| `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 <path>`. Without a local provider (mock) the path relative to the superproject is copied |
|
| `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 <path>`. 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 |
|
| `/` | Filter | Global | Help |
|
||||||
| `!` | Problems only (toggle) | Global | Help |
|
| `!` | Problems only (toggle) | Global | Help |
|
||||||
| `?` | Toggle full help | Global | Bar, 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
|
### Mouse
|
||||||
|
|
||||||
|
|||||||
+41
-2
@@ -61,11 +61,28 @@ type Model struct {
|
|||||||
pickerPath string // the repository the picker is for
|
pickerPath string // the repository the picker is for
|
||||||
notice string // an error to show above the footer until the next key
|
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
|
width, height int
|
||||||
showDetail bool
|
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()
|
t := theme.Default()
|
||||||
k := defaultKeys()
|
k := defaultKeys()
|
||||||
sp := spinner.New(spinner.WithSpinner(spinner.MiniDot))
|
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.fetcher, _ = p.(submodule.Fetcher)
|
||||||
m.watcher, _ = p.(submodule.Watcher)
|
m.watcher, _ = p.(submodule.Watcher)
|
||||||
|
for _, o := range opts {
|
||||||
|
o(&m)
|
||||||
|
}
|
||||||
return m
|
return m
|
||||||
}
|
}
|
||||||
|
|
||||||
func (m Model) Init() tea.Cmd {
|
func (m Model) Init() tea.Cmd {
|
||||||
m.loads.start(treeKey)
|
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 {
|
func (m Model) loadTree() tea.Cmd {
|
||||||
@@ -187,6 +214,15 @@ func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||||||
m.layout()
|
m.layout()
|
||||||
return m, nil
|
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:
|
case treeLoadedMsg:
|
||||||
return m.treeLoaded(msg)
|
return m.treeLoaded(msg)
|
||||||
|
|
||||||
@@ -361,6 +397,9 @@ func (m Model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
|||||||
m.closeFilter()
|
m.closeFilter()
|
||||||
case key.Matches(msg, k.Quit):
|
case key.Matches(msg, k.Quit):
|
||||||
return m, tea.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):
|
case key.Matches(msg, k.Help):
|
||||||
m.footer.ToggleHelp()
|
m.footer.ToggleHelp()
|
||||||
m.layout()
|
m.layout()
|
||||||
|
|||||||
@@ -19,13 +19,15 @@ const flashDuration = 2 * time.Second
|
|||||||
type clearFlashMsg struct{ id int }
|
type clearFlashMsg struct{ id int }
|
||||||
|
|
||||||
// Keys provides the key groups. The short bar shows Navigation (and
|
// 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.
|
// shows FullHelp.
|
||||||
type Keys interface {
|
type Keys interface {
|
||||||
help.KeyMap
|
help.KeyMap
|
||||||
Navigation() []key.Binding
|
Navigation() []key.Binding
|
||||||
Submodule() []key.Binding
|
Submodule() []key.Binding
|
||||||
Global() []key.Binding
|
Global() []key.Binding
|
||||||
|
Highlight() []key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
type Model struct {
|
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}
|
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
|
// SetActions shows the submodule bindings in the short bar. Set it if the
|
||||||
// detail pane doesn't show them.
|
// detail pane doesn't show them.
|
||||||
func (m *Model) SetActions(on bool) { m.actions = on }
|
func (m *Model) SetActions(on bool) { m.actions = on }
|
||||||
@@ -85,6 +90,13 @@ func (m Model) shortBar(w int) string {
|
|||||||
h := m.help
|
h := m.help
|
||||||
h.Width = 0
|
h.Width = 0
|
||||||
right := h.ShortHelpView(m.keys.Global())
|
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()
|
left := m.keys.Navigation()
|
||||||
if m.actions {
|
if m.actions {
|
||||||
left = append(left[:len(left):len(left)], m.keys.Submodule()...)
|
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
|
package tui
|
||||||
|
|
||||||
import "github.com/charmbracelet/bubbles/key"
|
import (
|
||||||
|
"github.com/charmbracelet/bubbles/key"
|
||||||
|
)
|
||||||
|
|
||||||
type keyMap struct {
|
type keyMap struct {
|
||||||
Up, Down, Left, Right, Toggle key.Binding
|
Up, Down, Left, Right, Toggle key.Binding
|
||||||
@@ -8,7 +10,7 @@ type keyMap struct {
|
|||||||
Home, End, PageUp, PageDown key.Binding
|
Home, End, PageUp, PageDown key.Binding
|
||||||
Fetch, Reload, Filter, Issues key.Binding
|
Fetch, Reload, Filter, Issues key.Binding
|
||||||
Copy, Restore, Switch key.Binding
|
Copy, Restore, Switch key.Binding
|
||||||
Help, Quit key.Binding
|
Help, Quit, QuitUpdate key.Binding
|
||||||
}
|
}
|
||||||
|
|
||||||
func defaultKeys() keyMap {
|
func defaultKeys() keyMap {
|
||||||
@@ -33,9 +35,17 @@ func defaultKeys() keyMap {
|
|||||||
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
Copy: key.NewBinding(key.WithKeys("c"), key.WithHelp("c", "copy path")),
|
||||||
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
Help: key.NewBinding(key.WithKeys("?"), key.WithHelp("?", "help")),
|
||||||
Quit: key.NewBinding(key.WithKeys("q", "ctrl+c"), key.WithHelp("q", "quit")),
|
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.
|
// Navigation returns the bindings that move the selection or fold the tree.
|
||||||
func (k keyMap) Navigation() []key.Binding {
|
func (k keyMap) Navigation() []key.Binding {
|
||||||
move := key.NewBinding(key.WithKeys("up"), key.WithHelp("↑↓/jk", "move"))
|
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.
|
// Global returns the bindings that don't depend on the selection.
|
||||||
func (k keyMap) Global() []key.Binding { return []key.Binding{k.Help, k.Quit} }
|
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 {
|
func (k keyMap) ShortHelp() []key.Binding {
|
||||||
return append(append(k.Navigation(), k.Submodule()...), k.Global()...)
|
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.Home, k.End, k.PageUp, k.PageDown},
|
||||||
{k.Copy, k.Switch, k.Restore},
|
{k.Copy, k.Switch, k.Restore},
|
||||||
{k.Fetch, k.Reload, k.Filter, k.Issues},
|
{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
|
change submodule.Change
|
||||||
closed bool // the watcher stopped
|
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
|
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.
|
// NewHelp returns a help renderer in the theme's colors.
|
||||||
func NewHelp(t Theme) help.Model {
|
func NewHelp(t Theme) help.Model {
|
||||||
@@ -13,3 +16,9 @@ func NewHelp(t Theme) help.Model {
|
|||||||
h.Styles.FullSeparator = h.Styles.ShortSeparator
|
h.Styles.FullSeparator = h.Styles.ShortSeparator
|
||||||
return h
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -209,20 +209,3 @@ func (l *limitWriter) Write(p []byte) (int, error) {
|
|||||||
}
|
}
|
||||||
return l.w.Write(p)
|
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
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user