From afc8fd0be1763d68a6461df1a3b290589ebdd8ad Mon Sep 17 00:00:00 2001 From: Raphael Amorim Date: Tue, 18 Nov 2025 12:22:40 +0100 Subject: [PATCH] feat: notify about new crush versions (#361) Signed-off-by: Carlos Alexandro Becker Co-authored-by: Carlos Alexandro Becker Co-authored-by: Andrey Nering --- internal/app/app.go | 19 +++ internal/pubsub/events.go | 6 + internal/tui/components/core/status/status.go | 4 +- internal/tui/tui.go | 11 ++ internal/tui/util/util.go | 1 + internal/update/update.go | 114 ++++++++++++++++++ internal/update/update_test.go | 53 ++++++++ internal/version/version.go | 10 +- 8 files changed, 209 insertions(+), 9 deletions(-) create mode 100644 internal/update/update.go create mode 100644 internal/update/update_test.go diff --git a/internal/app/app.go b/internal/app/app.go index d3e6d2133346df1adc11fc13a612b67cf25b46bd..847eb7161bf1a67ab6611ef566c30072600c000d 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -33,6 +33,8 @@ import ( "github.com/charmbracelet/crush/internal/term" "github.com/charmbracelet/crush/internal/tui/components/anim" "github.com/charmbracelet/crush/internal/tui/styles" + "github.com/charmbracelet/crush/internal/update" + "github.com/charmbracelet/crush/internal/version" "github.com/charmbracelet/x/ansi" "github.com/charmbracelet/x/exp/charmtone" ) @@ -92,6 +94,9 @@ func New(ctx context.Context, conn *sql.DB, cfg *config.Config) (*App, error) { // Initialize LSP clients in the background. app.initLSPClients(ctx) + // Check for updates in the background. + go app.checkForUpdates(ctx) + go func() { slog.Info("Initializing MCP clients") mcp.Initialize(ctx, app.Permissions, cfg) @@ -390,3 +395,17 @@ func (app *App) Shutdown() { } } } + +// checkForUpdates checks for available updates. +func (app *App) checkForUpdates(ctx context.Context) { + checkCtx, cancel := context.WithTimeout(ctx, 30*time.Second) + defer cancel() + info, err := update.Check(checkCtx, version.Version, update.Default) + if err != nil || !info.Available() { + return + } + app.events <- pubsub.UpdateAvailableMsg{ + CurrentVersion: info.Current, + LatestVersion: info.Latest, + } +} diff --git a/internal/pubsub/events.go b/internal/pubsub/events.go index 2fb0a741353bfc5054641815da9ad3292f49e6a3..27cd47c4061ccfd11e4a9250b1a2fe319e477044 100644 --- a/internal/pubsub/events.go +++ b/internal/pubsub/events.go @@ -26,3 +26,9 @@ type ( Publish(EventType, T) } ) + +// UpdateAvailableMsg is sent when a new version is available. +type UpdateAvailableMsg struct { + CurrentVersion string + LatestVersion string +} diff --git a/internal/tui/components/core/status/status.go b/internal/tui/components/core/status/status.go index 40c5309eb12d9c3361b4108b83f2c5165979eed8..66903704e3effcc800b36222381f05ca1be895aa 100644 --- a/internal/tui/components/core/status/status.go +++ b/internal/tui/components/core/status/status.go @@ -82,10 +82,10 @@ func (m *statusCmp) infoMsg() string { info := ansi.Truncate(m.info.Msg, widthLeft, "…") message = t.S().Base.Foreground(t.BgOverlay).Width(widthLeft+2).Background(t.Warning).Padding(0, 1).Render(info) default: - infoType = t.S().Base.Foreground(t.BgOverlay).Background(t.Green).Padding(0, 1).Render("OKAY!") + infoType = t.S().Base.Foreground(t.BgSubtle).Background(t.Green).Padding(0, 1).Bold(true).Render("HEY!") widthLeft := m.width - (lipgloss.Width(infoType) + 2) info := ansi.Truncate(m.info.Msg, widthLeft, "…") - message = t.S().Base.Background(t.Success).Width(widthLeft+2).Foreground(t.White).Padding(0, 1).Render(info) + message = t.S().Base.Background(t.GreenDark).Width(widthLeft+2).Foreground(t.BgSubtle).Padding(0, 1).Render(info) } return ansi.Truncate(infoType+message, m.width, "…") } diff --git a/internal/tui/tui.go b/internal/tui/tui.go index 793421f58307778307d95bda1c3aec13af7522eb..54bec287b2957fe97d377a3e34b9954c38338b71 100644 --- a/internal/tui/tui.go +++ b/internal/tui/tui.go @@ -372,6 +372,17 @@ func (a *appModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { cmds = append(cmds, pageCmd) } return a, tea.Batch(cmds...) + // Update Available + case pubsub.UpdateAvailableMsg: + // Show update notification in status bar + statusMsg := fmt.Sprintf("Crush update available: v%s → v%s.", msg.CurrentVersion, msg.LatestVersion) + s, statusCmd := a.status.Update(util.InfoMsg{ + Type: util.InfoTypeInfo, + Msg: statusMsg, + TTL: 30 * time.Second, + }) + a.status = s.(status.StatusCmp) + return a, statusCmd } s, _ := a.status.Update(msg) a.status = s.(status.StatusCmp) diff --git a/internal/tui/util/util.go b/internal/tui/util/util.go index 46af5beb8181968bae35356f6d2124561d9f51e7..297a9d36fa47170cae787c82419a17b51fc13b05 100644 --- a/internal/tui/util/util.go +++ b/internal/tui/util/util.go @@ -35,6 +35,7 @@ type InfoType int const ( InfoTypeInfo InfoType = iota + InfoTypeSuccess InfoTypeWarn InfoTypeError ) diff --git a/internal/update/update.go b/internal/update/update.go new file mode 100644 index 0000000000000000000000000000000000000000..061766c3a4eadfe5226dbc95a1afd2dfd677a957 --- /dev/null +++ b/internal/update/update.go @@ -0,0 +1,114 @@ +package update + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "time" +) + +const ( + githubAPIURL = "https://api.github.com/repos/charmbracelet/crush/releases/latest" + userAgent = "crush/1.0" +) + +// Default is the default [Client]. +var Default Client = &github{} + +// Info contains information about an available update. +type Info struct { + Current string + Latest string + URL string +} + +// Available returns true if there's an update available. +// +// If both current and latest are stable versions, returns true if versions are +// different. +// If current is a pre-release and latest isn't, returns true. +// If latest is a pre-release and current isn't, returns false. +func (i Info) Available() bool { + cpr := strings.Contains(i.Current, "-") + lpr := strings.Contains(i.Latest, "-") + // current is pre release + if cpr { + // latest isn't a prerelease + if !lpr { + return true + } + } + if lpr && !cpr { + return false + } + return i.Current != i.Latest +} + +// Check checks if a new version is available. +func Check(ctx context.Context, current string, client Client) (Info, error) { + info := Info{ + Current: current, + Latest: current, + } + + if info.Current == "devel" || info.Current == "unknown" { + return info, nil + } + + release, err := client.Latest(ctx) + if err != nil { + return info, fmt.Errorf("failed to fetch latest release: %w", err) + } + + info.Latest = strings.TrimPrefix(release.TagName, "v") + info.URL = release.HTMLURL + return info, nil +} + +// Release represents a GitHub release. +type Release struct { + TagName string `json:"tag_name"` + HTMLURL string `json:"html_url"` +} + +// Client is a client that can get the latest release. +type Client interface { + Latest(ctx context.Context) (*Release, error) +} + +type github struct{} + +// Latest implements [Client]. +func (c *github) Latest(ctx context.Context) (*Release, error) { + client := &http.Client{ + Timeout: 30 * time.Second, + } + + req, err := http.NewRequestWithContext(ctx, "GET", githubAPIURL, nil) + if err != nil { + return nil, err + } + req.Header.Set("User-Agent", userAgent) + req.Header.Set("Accept", "application/vnd.github.v3+json") + + resp, err := client.Do(req) + if err != nil { + return nil, err + } + defer resp.Body.Close() + + if resp.StatusCode != http.StatusOK { + body, _ := io.ReadAll(resp.Body) + return nil, fmt.Errorf("GitHub API returned status %d: %s", resp.StatusCode, string(body)) + } + + var release Release + if err := json.NewDecoder(resp.Body).Decode(&release); err != nil { + return nil, err + } + + return &release, nil +} diff --git a/internal/update/update_test.go b/internal/update/update_test.go new file mode 100644 index 0000000000000000000000000000000000000000..e833ad220705837a0f18cbc4b6abf6338987644c --- /dev/null +++ b/internal/update/update_test.go @@ -0,0 +1,53 @@ +package update + +import ( + "context" + "testing" + + "github.com/stretchr/testify/require" +) + +func TestCheckForUpdate_DevelopmentVersion(t *testing.T) { + info, err := Check(t.Context(), "unknown", testClient{"v0.11.0"}) + require.NoError(t, err) + require.NotNil(t, info) + require.False(t, info.Available()) +} + +func TestCheckForUpdate_Old(t *testing.T) { + info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0"}) + require.NoError(t, err) + require.NotNil(t, info) + require.True(t, info.Available()) +} + +func TestCheckForUpdate_Beta(t *testing.T) { + t.Run("current is stable", func(t *testing.T) { + info, err := Check(t.Context(), "v0.10.0", testClient{"v0.11.0-beta.1"}) + require.NoError(t, err) + require.NotNil(t, info) + require.False(t, info.Available()) + }) + t.Run("current is also beta", func(t *testing.T) { + info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0-beta.2"}) + require.NoError(t, err) + require.NotNil(t, info) + require.True(t, info.Available()) + }) + t.Run("current is beta, latest isn't", func(t *testing.T) { + info, err := Check(t.Context(), "v0.11.0-beta.1", testClient{"v0.11.0"}) + require.NoError(t, err) + require.NotNil(t, info) + require.True(t, info.Available()) + }) +} + +type testClient struct{ tag string } + +// Latest implements Client. +func (t testClient) Latest(ctx context.Context) (*Release, error) { + return &Release{ + TagName: t.tag, + HTMLURL: "https://example.org", + }, nil +} diff --git a/internal/version/version.go b/internal/version/version.go index 0b616e122dcf4ffb3fbbf4cb7d3b8665300c23ef..6faef3251ca071a0a210ac1bc2327ca848a73ad0 100644 --- a/internal/version/version.go +++ b/internal/version/version.go @@ -4,7 +4,7 @@ import "runtime/debug" // Build-time parameters set via -ldflags -var Version = "unknown" +var Version = "devel" // A user may install crush using `go install github.com/charmbracelet/crush@latest`. // without -ldflags, in which case the version above is unset. As a workaround @@ -13,14 +13,10 @@ var Version = "unknown" func init() { info, ok := debug.ReadBuildInfo() if !ok { - // < go v1.18 return } mainVersion := info.Main.Version - if mainVersion == "" || mainVersion == "(devel)" { - // bin not built using `go install` - return + if mainVersion != "" && mainVersion != "(devel)" { + Version = mainVersion } - // bin built using `go install` - Version = mainVersion }