Skip to content

Commit 83f6741

Browse files
committed
Simplify update notification flow
1 parent a7f6804 commit 83f6741

File tree

11 files changed

+263
-60
lines changed

11 files changed

+263
-60
lines changed

cmd/root.go

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -121,9 +121,8 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
121121
}
122122

123123
notifyOpts := update.NotifyOptions{
124-
GitHubToken: cfg.GitHubToken,
125-
UpdatePrompt: appConfig.UpdatePrompt,
126-
PersistDisable: config.DisableUpdatePrompt,
124+
GitHubToken: cfg.GitHubToken,
125+
UpdatePrompt: appConfig.CLI.UpdatePrompt,
127126
}
128127

129128
if isInteractiveMode(cfg) {

internal/config/config.go

Lines changed: 25 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -13,10 +13,15 @@ import (
1313
//go:embed default_config.toml
1414
var defaultConfigTemplate string
1515

16+
type CLIConfig struct {
17+
UpdatePrompt bool `mapstructure:"update_prompt"`
18+
UpdateSkippedVersion string `mapstructure:"update_skipped_version"`
19+
}
20+
1621
type Config struct {
17-
Containers []ContainerConfig `mapstructure:"containers"`
18-
Env map[string]map[string]string `mapstructure:"env"`
19-
UpdatePrompt bool `mapstructure:"update_prompt"`
22+
Containers []ContainerConfig `mapstructure:"containers"`
23+
Env map[string]map[string]string `mapstructure:"env"`
24+
CLI CLIConfig `mapstructure:"cli"`
2025
}
2126

2227
func setDefaults() {
@@ -27,7 +32,8 @@ func setDefaults() {
2732
"port": "4566",
2833
},
2934
})
30-
viper.SetDefault("update_prompt", true)
35+
viper.SetDefault("cli.update_prompt", true)
36+
viper.SetDefault("cli.update_skipped_version", "")
3137
}
3238

3339
func loadConfig(path string) error {
@@ -110,14 +116,28 @@ func Set(key string, value any) error {
110116
}
111117

112118
func DisableUpdatePrompt() error {
113-
return Set("update_prompt", false)
119+
return Set("cli.update_prompt", false)
120+
}
121+
122+
func SetUpdateSkippedVersion(version string) error {
123+
return Set("cli.update_skipped_version", version)
124+
}
125+
126+
func GetUpdateSkippedVersion() string {
127+
return viper.GetString("cli.update_skipped_version")
114128
}
115129

116130
func Get() (*Config, error) {
117131
var cfg Config
118132
if err := viper.Unmarshal(&cfg); err != nil {
119133
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
120134
}
135+
if !viper.IsSet("cli.update_prompt") && viper.IsSet("update_prompt") {
136+
cfg.CLI.UpdatePrompt = viper.GetBool("update_prompt")
137+
}
138+
if !viper.IsSet("cli.update_skipped_version") && viper.IsSet("update_skipped_version") {
139+
cfg.CLI.UpdateSkippedVersion = viper.GetString("update_skipped_version")
140+
}
121141
for i := range cfg.Containers {
122142
if err := cfg.Containers[i].Validate(); err != nil {
123143
return nil, fmt.Errorf("invalid container config: %w", err)

internal/config/default_config.toml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
# lstk configuration file
22
# Run 'lstk config path' to see where this file lives.
33

4+
# CLI settings
5+
[cli]
6+
update_prompt = true
7+
48
# Each [[containers]] block defines an emulator instance.
59
# You can define multiple to run them side by side.
610
[[containers]]

internal/ui/app.go

Lines changed: 37 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -95,6 +95,26 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
9595
return a, tea.Quit
9696
}
9797
if a.pendingInput != nil {
98+
if componentsUsesVerticalPrompt(a.inputPrompt, a.pendingInput.Options) {
99+
switch msg.Type {
100+
case tea.KeyUp:
101+
a.inputPrompt = a.inputPrompt.SetSelectedIndex(a.inputPrompt.SelectedIndex() - 1)
102+
return a, nil
103+
case tea.KeyDown:
104+
a.inputPrompt = a.inputPrompt.SetSelectedIndex(a.inputPrompt.SelectedIndex() + 1)
105+
return a, nil
106+
case tea.KeyEnter:
107+
idx := a.inputPrompt.SelectedIndex()
108+
if idx >= 0 && idx < len(a.pendingInput.Options) {
109+
opt := a.pendingInput.Options[idx]
110+
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
111+
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
112+
a.pendingInput = nil
113+
a.inputPrompt = a.inputPrompt.Hide()
114+
return a, responseCmd
115+
}
116+
}
117+
}
98118
if opt := resolveOption(a.pendingInput.Options, msg); opt != nil {
99119
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
100120
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
@@ -296,6 +316,19 @@ func (a *App) flushBufferedLines() {
296316
}
297317

298318
func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) string {
319+
// Special handling for lstk update prompt
320+
if len(req.Options) > 0 && strings.Contains(req.Options[0].Label, "Update now") {
321+
checkmark := styles.Success.Render(output.SuccessMarker())
322+
switch selectedKey {
323+
case "u":
324+
return checkmark + " Updating lstk..."
325+
case "s":
326+
return checkmark + " Skipped this version"
327+
case "n":
328+
return checkmark + " Won't ask again"
329+
}
330+
}
331+
299332
formatted := output.FormatPrompt(req.Prompt, req.Options)
300333
firstLine := strings.Split(formatted, "\n")[0]
301334

@@ -316,6 +349,10 @@ func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) s
316349
return fmt.Sprintf("%s %s", firstLine, selected)
317350
}
318351

352+
func componentsUsesVerticalPrompt(prompt components.InputPrompt, options []output.InputOption) bool {
353+
return prompt.Visible() && len(options) > 1 && strings.Contains(options[0].Label, "[")
354+
}
355+
319356
// resolveOption finds the best matching option for a key event, in priority order:
320357
// 1. "any" — matches any keypress
321358
// 2. "enter" — matches the Enter key explicitly

internal/ui/app_test.go

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -410,6 +410,47 @@ func TestAppEnterDoesNothingWithNonLetterLabel(t *testing.T) {
410410
}
411411
}
412412

413+
func TestAppEnterSelectsHighlightedVerticalOption(t *testing.T) {
414+
t.Parallel()
415+
416+
app := NewApp("dev", "", "", nil)
417+
responseCh := make(chan output.InputResponse, 1)
418+
419+
model, _ := app.Update(output.UserInputRequestEvent{
420+
Prompt: "Update lstk to latest version?",
421+
Options: []output.InputOption{
422+
{Key: "u", Label: "Update now [U]"},
423+
{Key: "s", Label: "Skip this version [S]"},
424+
{Key: "n", Label: "Never ask again [N]"},
425+
},
426+
ResponseCh: responseCh,
427+
})
428+
app = model.(App)
429+
430+
model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
431+
app = model.(App)
432+
433+
model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter})
434+
app = model.(App)
435+
if cmd == nil {
436+
t.Fatal("expected response command when enter is pressed on vertical prompt")
437+
}
438+
cmd()
439+
440+
select {
441+
case resp := <-responseCh:
442+
if resp.SelectedKey != "s" {
443+
t.Fatalf("expected s key, got %q", resp.SelectedKey)
444+
}
445+
case <-time.After(time.Second):
446+
t.Fatal("timed out waiting for response on channel")
447+
}
448+
449+
if app.inputPrompt.Visible() {
450+
t.Fatal("expected input prompt to be hidden after response")
451+
}
452+
}
453+
413454
func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) {
414455
t.Parallel()
415456

internal/ui/components/input_prompt.go

Lines changed: 53 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -8,9 +8,10 @@ import (
88
)
99

1010
type InputPrompt struct {
11-
prompt string
12-
options []output.InputOption
13-
visible bool
11+
prompt string
12+
options []output.InputOption
13+
visible bool
14+
selectedIndex int
1415
}
1516

1617
func NewInputPrompt() InputPrompt {
@@ -21,6 +22,7 @@ func (p InputPrompt) Show(prompt string, options []output.InputOption) InputProm
2122
p.prompt = prompt
2223
p.options = options
2324
p.visible = true
25+
p.selectedIndex = 0
2426
return p
2527
}
2628

@@ -33,20 +35,31 @@ func (p InputPrompt) Visible() bool {
3335
return p.visible
3436
}
3537

38+
func (p InputPrompt) SelectedIndex() int {
39+
return p.selectedIndex
40+
}
41+
42+
func (p InputPrompt) SetSelectedIndex(idx int) InputPrompt {
43+
if idx >= 0 && idx < len(p.options) {
44+
p.selectedIndex = idx
45+
}
46+
return p
47+
}
48+
3649
func (p InputPrompt) View() string {
3750
if !p.visible {
3851
return ""
3952
}
4053

41-
lines := strings.Split(p.prompt, "\n")
54+
if usesVerticalOptions(p.options) {
55+
return p.viewVertical()
56+
}
4257

58+
lines := strings.Split(p.prompt, "\n")
4359
firstLine := lines[0]
4460

4561
var sb strings.Builder
46-
47-
// "?" prefix in secondary color
4862
sb.WriteString(styles.Secondary.Render("? "))
49-
5063
sb.WriteString(styles.Message.Render(firstLine))
5164

5265
if suffix := output.FormatPromptLabels(p.options); suffix != "" {
@@ -60,3 +73,36 @@ func (p InputPrompt) View() string {
6073

6174
return sb.String()
6275
}
76+
77+
func (p InputPrompt) viewVertical() string {
78+
var sb strings.Builder
79+
80+
if p.prompt != "" {
81+
sb.WriteString(styles.Secondary.Render("? "))
82+
sb.WriteString(styles.Message.Render(strings.TrimPrefix(p.prompt, "? ")))
83+
sb.WriteString("\n")
84+
}
85+
86+
for _, opt := range p.options {
87+
if p.options[p.selectedIndex].Key == opt.Key {
88+
sb.WriteString(styles.NimboMid.Render("● " + opt.Label))
89+
} else {
90+
sb.WriteString(styles.Secondary.Render("○ " + opt.Label))
91+
}
92+
sb.WriteString("\n")
93+
}
94+
95+
return sb.String()
96+
}
97+
98+
func usesVerticalOptions(options []output.InputOption) bool {
99+
if len(options) < 2 {
100+
return false
101+
}
102+
for _, opt := range options {
103+
if strings.Contains(opt.Label, "[") && strings.Contains(opt.Label, "]") {
104+
return true
105+
}
106+
}
107+
return false
108+
}

internal/ui/components/message.go

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -14,20 +14,22 @@ func RenderMessage(e output.MessageEvent) string {
1414

1515
func RenderWrappedMessage(e output.MessageEvent, width int) string {
1616
prefixText, prefix := messagePrefix(e)
17+
text := e.Text
18+
1719
if prefixText == "" {
1820
style := styles.Message
1921
if e.Severity == output.SeveritySecondary {
2022
style = styles.SecondaryMessage
2123
}
22-
return style.Render(strings.Join(wrap.SoftWrap(e.Text, width), "\n"))
24+
return style.Render(strings.Join(wrap.SoftWrap(text, width), "\n"))
2325
}
2426

2527
if width <= len([]rune(prefixText))+1 {
26-
return prefix + " " + styles.Message.Render(e.Text)
28+
return prefix + " " + styles.Message.Render(text)
2729
}
2830

2931
availableWidth := width - len([]rune(prefixText)) - 1
30-
lines := wrap.SoftWrap(e.Text, availableWidth)
32+
lines := wrap.SoftWrap(text, availableWidth)
3133
if len(lines) == 0 {
3234
return prefix
3335
}

internal/update/github.go

Lines changed: 12 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -37,22 +37,30 @@ func githubRequest(ctx context.Context, url, token string) (*http.Response, erro
3737
return http.DefaultClient.Do(req)
3838
}
3939

40-
func fetchLatestVersion(ctx context.Context, token string) (string, error) {
40+
func fetchLatestRelease(ctx context.Context, token string) (*githubRelease, error) {
4141
resp, err := githubRequest(ctx, latestReleaseURL, token)
4242
if err != nil {
43-
return "", err
43+
return nil, err
4444
}
4545
defer func() { _ = resp.Body.Close() }()
4646

4747
if resp.StatusCode != http.StatusOK {
48-
return "", fmt.Errorf("GitHub API returned %s", resp.Status)
48+
return nil, fmt.Errorf("GitHub API returned %s", resp.Status)
4949
}
5050

5151
var release githubRelease
5252
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
53-
return "", err
53+
return nil, err
5454
}
5555

56+
return &release, nil
57+
}
58+
59+
func fetchLatestVersion(ctx context.Context, token string) (string, error) {
60+
release, err := fetchLatestRelease(ctx, token)
61+
if err != nil {
62+
return "", err
63+
}
5664
return release.TagName, nil
5765
}
5866

0 commit comments

Comments
 (0)