Skip to content

Commit 0057ecf

Browse files
authored
Merge pull request #43 from SkeneTechnologies/feat/update-checker
Show update notification on TUI welcome screen
2 parents b8554b8 + 8bf785d commit 0057ecf

5 files changed

Lines changed: 214 additions & 5 deletions

File tree

tui/internal/constants/strings.go

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -124,6 +124,13 @@ const (
124124
WelcomeCTA = "> ENTER <"
125125
)
126126

127+
// Update notice (welcome view)
128+
const (
129+
UpdateNoticeFormat = "Update available: %s (current: %s)"
130+
UpdateNoticeHintCopy = "Press c to copy update command"
131+
UpdateNoticeCopied = "Copied to clipboard!"
132+
)
133+
127134
// Auth view
128135
const (
129136
AuthOpeningBrowser = "Opening browser for Skene authentication"
@@ -222,6 +229,7 @@ const (
222229
HelpKeyG = "g"
223230
HelpKeyM = "m"
224231
HelpKeyR = "r"
232+
HelpKeyC = "c"
225233
)
226234

227235
// Help descriptions
@@ -265,4 +273,5 @@ const (
265273
HelpDescToggleOption = "toggle option"
266274
HelpDescOpenFolder = "open folder"
267275
HelpDescTabs = "tabs"
276+
HelpDescCopyUpdateCmd = "copy update cmd"
268277
)
Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,98 @@
1+
package versioncheck
2+
3+
import (
4+
"encoding/json"
5+
"fmt"
6+
"net/http"
7+
"strings"
8+
"time"
9+
10+
"skene/internal/constants"
11+
)
12+
13+
// Result holds the outcome of a version check.
14+
type Result struct {
15+
// NewVersion is set when an update is available (e.g. "v0.4.0").
16+
NewVersion string
17+
// UpdateCmd is the command users can run to update.
18+
UpdateCmd string
19+
}
20+
21+
// githubRelease is the subset of the GitHub API response we care about.
22+
type githubRelease struct {
23+
TagName string `json:"tag_name"`
24+
}
25+
26+
// Check queries GitHub for the latest TUI release and compares it to the
27+
// running binary's version. Returns nil when already up-to-date or when
28+
// the check cannot be performed (network error, dev build, etc.).
29+
func Check() *Result {
30+
if constants.Version == "dev" {
31+
return nil
32+
}
33+
34+
client := &http.Client{Timeout: 3 * time.Second}
35+
36+
// List recent releases and find the latest tui-v* tag.
37+
// constants.Repository includes the "github.com/" prefix, strip it for the API.
38+
repo := strings.TrimPrefix(constants.Repository, "github.com/")
39+
url := fmt.Sprintf("https://api.github.com/repos/%s/releases?per_page=20", repo)
40+
resp, err := client.Get(url)
41+
if err != nil || resp.StatusCode != http.StatusOK {
42+
return nil
43+
}
44+
defer resp.Body.Close()
45+
46+
var releases []githubRelease
47+
if err := json.NewDecoder(resp.Body).Decode(&releases); err != nil {
48+
return nil
49+
}
50+
51+
latest := latestTUITag(releases)
52+
if latest == "" {
53+
return nil
54+
}
55+
56+
// Strip "tui-" prefix to compare just the version part.
57+
latestVersion := strings.TrimPrefix(latest, "tui-")
58+
if latestVersion == constants.Version {
59+
return nil
60+
}
61+
62+
return &Result{
63+
NewVersion: latestVersion,
64+
UpdateCmd: "curl -fsSL https://raw.githubusercontent.com/SkeneTechnologies/skene/main/tui/install.sh | bash",
65+
}
66+
}
67+
68+
// latestTUITag returns the first tui-v* tag from the releases list,
69+
// which GitHub returns in reverse chronological order. Pre-release
70+
// tags are skipped.
71+
func latestTUITag(releases []githubRelease) string {
72+
for _, r := range releases {
73+
if strings.HasPrefix(r.TagName, "tui-v") && !isPreRelease(r.TagName) {
74+
return r.TagName
75+
}
76+
}
77+
return ""
78+
}
79+
80+
// isPreRelease returns true for tags like tui-v0.3.0rc1, tui-v0.3.0a1, etc.
81+
func isPreRelease(tag string) bool {
82+
v := strings.TrimPrefix(tag, "tui-")
83+
for _, suffix := range []string{"rc", "alpha", "beta"} {
84+
if strings.Contains(v, suffix) {
85+
return true
86+
}
87+
}
88+
// Check for "a" or "b" followed by a digit (e.g. v0.3.0a1)
89+
for i := 0; i < len(v)-1; i++ {
90+
if (v[i] == 'a' || v[i] == 'b') && v[i+1] >= '0' && v[i+1] <= '9' {
91+
// Make sure it's not part of a hex-like version segment
92+
if i == 0 || v[i-1] == '.' || v[i-1] >= '0' && v[i-1] <= '9' {
93+
return true
94+
}
95+
}
96+
}
97+
return false
98+
}

tui/internal/tui/app.go

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,11 @@ import (
88

99
"skene/internal/constants"
1010
"skene/internal/game"
11+
"github.com/atotto/clipboard"
1112
"skene/internal/services/auth"
1213
"skene/internal/services/config"
1314
"skene/internal/services/growth"
15+
"skene/internal/services/versioncheck"
1416
"skene/internal/tui/components"
1517
"skene/internal/tui/styles"
1618
"skene/internal/tui/views"
@@ -97,6 +99,11 @@ type AuthCallbackMsg struct {
9799
Error error
98100
}
99101

102+
// VersionCheckMsg is sent when the background version check completes
103+
type VersionCheckMsg struct {
104+
Result *versioncheck.Result
105+
}
106+
100107
// authVerifiedMsg triggers the transition from verifying to success state
101108
type authVerifiedMsg struct{}
102109

@@ -201,6 +208,7 @@ func (a *App) Init() tea.Cmd {
201208
var cmds []tea.Cmd
202209
cmds = append(cmds, tick())
203210
cmds = append(cmds, textinput.Blink)
211+
cmds = append(cmds, checkForUpdate())
204212
// Initialize welcome animation
205213
if a.welcomeView != nil {
206214
animCmd := a.welcomeView.InitAnimation()
@@ -312,6 +320,11 @@ func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
312320
cmds = append(cmds, countdown(a.authCountdown-1))
313321
}
314322

323+
case VersionCheckMsg:
324+
if msg.Result != nil && a.welcomeView != nil {
325+
a.welcomeView.SetUpdateAvailable(msg.Result.NewVersion, msg.Result.UpdateCmd)
326+
}
327+
315328
case AnalysisDoneMsg:
316329
err := msg.Error
317330
if err == nil && msg.Result != nil && msg.Result.Error != nil {
@@ -534,6 +547,13 @@ func (a *App) handleWelcomeKeys(key string) tea.Cmd {
534547
a.providerView.SetSize(a.width, a.height)
535548
}
536549
return nil
550+
case "c":
551+
if a.welcomeView != nil && a.welcomeView.HasUpdate() {
552+
if clipboard.WriteAll(a.welcomeView.GetUpdateCmd()) == nil {
553+
a.welcomeView.SetCopied()
554+
}
555+
}
556+
return nil
537557
}
538558
return nil
539559
}
@@ -1654,6 +1674,12 @@ func tick() tea.Cmd {
16541674
})
16551675
}
16561676

1677+
func checkForUpdate() tea.Cmd {
1678+
return func() tea.Msg {
1679+
return VersionCheckMsg{Result: versioncheck.Check()}
1680+
}
1681+
}
1682+
16571683
func countdown(seconds int) tea.Cmd {
16581684
return tea.Tick(time.Second, func(t time.Time) tea.Msg {
16591685
return CountdownMsg(seconds)

tui/internal/tui/styles/styles.go

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,12 @@ var FooterHelp lipgloss.Style
174174
// Spinner style
175175
var Spinner lipgloss.Style
176176

177+
// UpdateNotice style — framed box for update + hint
178+
var UpdateNotice lipgloss.Style
179+
180+
// UpdateNoticeText style — main update message (white)
181+
var UpdateNoticeText lipgloss.Style
182+
177183
// rebuildStyles constructs all lipgloss styles from the current color
178184
// variables. Called by Init() after colors have been set.
179185
func rebuildStyles() {
@@ -290,6 +296,15 @@ func rebuildStyles() {
290296

291297
// Spinner style
292298
Spinner = lipgloss.NewStyle().Foreground(Amber)
299+
300+
// UpdateNotice — bordered box for update + hint (no bg; centered text)
301+
UpdateNotice = lipgloss.NewStyle().
302+
Border(lipgloss.RoundedBorder()).
303+
BorderForeground(MidGray).
304+
Padding(1, 1).
305+
Align(lipgloss.Center).
306+
MarginTop(1)
307+
UpdateNoticeText = lipgloss.NewStyle().Foreground(White)
293308
}
294309

295310
// init sets up the default dark-theme styles at package load time.

tui/internal/tui/views/welcome.go

Lines changed: 66 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
package views
22

33
import (
4+
"fmt"
5+
46
tea "github.com/charmbracelet/bubbletea"
57
"skene/internal/constants"
68
"skene/internal/tui/components"
@@ -15,6 +17,11 @@ type WelcomeView struct {
1517
height int
1618
time float64
1719
anim components.ASCIIMotionModel
20+
21+
// Update notification (set asynchronously)
22+
newVersion string
23+
updateCmd string
24+
copied bool
1825
}
1926

2027
// NewWelcomeView creates a new welcome view
@@ -49,6 +56,27 @@ func (v *WelcomeView) InitAnimation() tea.Cmd {
4956
return v.anim.Init()
5057
}
5158

59+
// SetUpdateAvailable sets the update notification info
60+
func (v *WelcomeView) SetUpdateAvailable(newVersion, updateCmd string) {
61+
v.newVersion = newVersion
62+
v.updateCmd = updateCmd
63+
}
64+
65+
// HasUpdate returns true if an update notification is present.
66+
func (v *WelcomeView) HasUpdate() bool {
67+
return v.newVersion != ""
68+
}
69+
70+
// GetUpdateCmd returns the update command string.
71+
func (v *WelcomeView) GetUpdateCmd() string {
72+
return v.updateCmd
73+
}
74+
75+
// SetCopied marks the update command as copied to clipboard.
76+
func (v *WelcomeView) SetCopied() {
77+
v.copied = true
78+
}
79+
5280
// ResetAnimation recreates the animation so it plays from the start
5381
func (v *WelcomeView) ResetAnimation() tea.Cmd {
5482
v.anim = components.NewASCIIMotion(styles.IsDarkBackground)
@@ -79,15 +107,37 @@ func (v *WelcomeView) Render() string {
79107
// Version info
80108
version := center.Render(styles.Muted.Render(constants.Version + " • " + constants.Repository))
81109

110+
// Update notification — bordered block with update + hint
111+
var updateNotice string
112+
if v.newVersion != "" {
113+
notice := fmt.Sprintf(constants.UpdateNoticeFormat, v.newVersion, constants.Version)
114+
hintRaw := constants.UpdateNoticeHintCopy
115+
if v.copied {
116+
hintRaw = constants.UpdateNoticeCopied
117+
}
118+
hint := styles.Muted.Render(hintRaw)
119+
blockWidth := lipgloss.Width(notice)
120+
if w := lipgloss.Width(hintRaw); w > blockWidth {
121+
blockWidth = w
122+
}
123+
blockWidth += 4 // padding
124+
content := styles.UpdateNoticeText.Render(notice) + "\n" + hint
125+
block := styles.UpdateNotice.Width(blockWidth).Render(content)
126+
updateNotice = center.Render(block)
127+
}
128+
82129
// Footer help
83-
footer := components.FooterHelp([]components.HelpItem{
130+
helpItems := []components.HelpItem{
84131
{Key: constants.HelpKeyEnter, Desc: constants.HelpDescStart},
85132
{Key: constants.HelpKeyCtrlC, Desc: constants.HelpDescQuit},
86-
})
133+
}
134+
if v.newVersion != "" {
135+
helpItems = append(helpItems, components.HelpItem{Key: constants.HelpKeyC, Desc: constants.HelpDescCopyUpdateCmd})
136+
}
137+
footer := components.FooterHelp(helpItems)
87138

88139
// Combine elements
89-
content := lipgloss.JoinVertical(
90-
lipgloss.Center,
140+
elements := []string{
91141
logo,
92142
"",
93143
"",
@@ -96,6 +146,13 @@ func (v *WelcomeView) Render() string {
96146
subtitle,
97147
"",
98148
version,
149+
}
150+
if updateNotice != "" {
151+
elements = append(elements, "", updateNotice)
152+
}
153+
content := lipgloss.JoinVertical(
154+
lipgloss.Center,
155+
elements...,
99156
)
100157

101158
centered := lipgloss.Place(
@@ -118,8 +175,12 @@ func (v *WelcomeView) Render() string {
118175

119176
// GetHelpItems returns context-specific help
120177
func (v *WelcomeView) GetHelpItems() []components.HelpItem {
121-
return []components.HelpItem{
178+
items := []components.HelpItem{
122179
{Key: constants.HelpKeyEnter, Desc: constants.HelpDescStart},
123180
{Key: constants.HelpKeyCtrlC, Desc: constants.HelpDescQuit},
124181
}
182+
if v.newVersion != "" {
183+
items = append(items, components.HelpItem{Key: constants.HelpKeyC, Desc: constants.HelpDescCopyUpdateCmd})
184+
}
185+
return items
125186
}

0 commit comments

Comments
 (0)