Detailed changes
@@ -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,
+ }
+}
@@ -26,3 +26,9 @@ type (
Publish(EventType, T)
}
)
+
+// UpdateAvailableMsg is sent when a new version is available.
+type UpdateAvailableMsg struct {
+ CurrentVersion string
+ LatestVersion string
+}
@@ -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, "…")
}
@@ -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)
@@ -35,6 +35,7 @@ type InfoType int
const (
InfoTypeInfo InfoType = iota
+ InfoTypeSuccess
InfoTypeWarn
InfoTypeError
)
@@ -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
+}
@@ -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
+}
@@ -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
}