feat: notify about new crush versions (#361)

Raphael Amorim , Carlos Alexandro Becker , and Andrey Nering created

Signed-off-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Carlos Alexandro Becker <caarlos0@users.noreply.github.com>
Co-authored-by: Andrey Nering <andreynering@users.noreply.github.com>

Change summary

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(-)

Detailed changes

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,
+	}
+}

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
+}

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, "…")
 }

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)

internal/tui/util/util.go 🔗

@@ -35,6 +35,7 @@ type InfoType int
 
 const (
 	InfoTypeInfo InfoType = iota
+	InfoTypeSuccess
 	InfoTypeWarn
 	InfoTypeError
 )

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
+}

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
+}

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
 }