Archived
Compare commits
1
Commits
e8d366d88f
..
v0.9.0
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4ac29c04d0 |
@@ -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
|
||||
|
||||
+28
-28
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
+19
-1
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
+8
-3
@@ -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 <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 |
|
||||
| `!` | 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
|
||||
|
||||
|
||||
+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())
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user