Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 2 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,8 @@ func startEmulator(ctx context.Context, rt runtime.Runtime, cfg *env.Env, tel *t
}

notifyOpts := update.NotifyOptions{
GitHubToken: cfg.GitHubToken,
UpdatePrompt: appConfig.UpdatePrompt,
PersistDisable: config.DisableUpdatePrompt,
GitHubToken: cfg.GitHubToken,
UpdatePrompt: appConfig.CLI.UpdatePrompt,
}

if isInteractiveMode(cfg) {
Expand Down
17 changes: 12 additions & 5 deletions internal/config/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -13,10 +13,14 @@ import (
//go:embed default_config.toml
var defaultConfigTemplate string

type CLIConfig struct {
UpdatePrompt bool `mapstructure:"update_prompt"`
}

type Config struct {
Containers []ContainerConfig `mapstructure:"containers"`
Env map[string]map[string]string `mapstructure:"env"`
UpdatePrompt bool `mapstructure:"update_prompt"`
Containers []ContainerConfig `mapstructure:"containers"`
Env map[string]map[string]string `mapstructure:"env"`
CLI CLIConfig `mapstructure:"cli"`
}

func setDefaults() {
Expand All @@ -27,7 +31,7 @@ func setDefaults() {
"port": "4566",
},
})
viper.SetDefault("update_prompt", true)
viper.SetDefault("cli.update_prompt", true)
}

func loadConfig(path string) error {
Expand Down Expand Up @@ -110,14 +114,17 @@ func Set(key string, value any) error {
}

func DisableUpdatePrompt() error {
return Set("update_prompt", false)
return Set("cli.update_prompt", false)
}

func Get() (*Config, error) {
var cfg Config
if err := viper.Unmarshal(&cfg); err != nil {
return nil, fmt.Errorf("failed to unmarshal config: %w", err)
}
if !viper.InConfig("cli.update_prompt") && viper.InConfig("update_prompt") {
cfg.CLI.UpdatePrompt = viper.GetBool("update_prompt")
}
for i := range cfg.Containers {
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: this looks like migration code, is this right?

  • update_prompt: I think we could ignore the fact that some users previously had this setting one level up in the config. They would just be asked again and in exchange we get rid of some complexity.
  • update_skipped_version: This is introduced in this PR so there is nothing to migrate.
    In short: can we remove these lines?

if err := cfg.Containers[i].Validate(); err != nil {
return nil, fmt.Errorf("invalid container config: %w", err)
Expand Down
1 change: 1 addition & 0 deletions internal/output/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,7 @@ type UserInputRequestEvent struct {
Prompt string
Options []InputOption
ResponseCh chan<- InputResponse
Vertical bool
}

const (
Expand Down
34 changes: 33 additions & 1 deletion internal/ui/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -95,6 +95,9 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return a, tea.Quit
}
if a.pendingInput != nil {
if a.pendingInput.Vertical {
return a.handleVerticalPromptKey(msg)
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: the Update method is already doing a lot and the PR adds another ~20 lines. Can we extract this in a new method, something like:

func (a App) handleVerticalPromptKey(msg tea.KeyMsg) (App, tea.Cmd, bool) {
      ...
      return a, nil, false
}

if opt := resolveOption(a.pendingInput.Options, msg); opt != nil {
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
Expand All @@ -110,7 +113,7 @@ func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if a.spinner.Visible() {
a.spinner = a.spinner.SetText(output.FormatPrompt(msg.Prompt, msg.Options))
} else {
a.inputPrompt = a.inputPrompt.Show(msg.Prompt, msg.Options)
a.inputPrompt = a.inputPrompt.Show(msg.Prompt, msg.Options, msg.Vertical)
}
case spinner.TickMsg:
var cmd tea.Cmd
Expand Down Expand Up @@ -295,6 +298,35 @@ func (a *App) flushBufferedLines() {
a.bufferedLines = nil
}

func (a App) handleVerticalPromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.Type {
case tea.KeyUp:
a.inputPrompt = a.inputPrompt.SetSelectedIndex(a.inputPrompt.SelectedIndex() - 1)
return a, nil
case tea.KeyDown:
a.inputPrompt = a.inputPrompt.SetSelectedIndex(a.inputPrompt.SelectedIndex() + 1)
return a, nil
case tea.KeyEnter:
idx := a.inputPrompt.SelectedIndex()
if idx >= 0 && idx < len(a.pendingInput.Options) {
opt := a.pendingInput.Options[idx]
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
a.pendingInput = nil
a.inputPrompt = a.inputPrompt.Hide()
return a, responseCmd
}
}
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue: this file is generic UI rendering code, it should not know anything about the update logic.
We need to find another solution for this. Maybe a confirmation text can be added on the input options.

if opt := resolveOption(a.pendingInput.Options, msg); opt != nil {
a.lines = appendLine(a.lines, styledLine{text: formatResolvedInput(*a.pendingInput, opt.Key)})
responseCmd := sendInputResponseCmd(a.pendingInput.ResponseCh, output.InputResponse{SelectedKey: opt.Key})
a.pendingInput = nil
a.inputPrompt = a.inputPrompt.Hide()
return a, responseCmd
}
return a, nil
}

func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) string {
formatted := output.FormatPrompt(req.Prompt, req.Options)
firstLine := strings.Split(formatted, "\n")[0]
Expand Down
38 changes: 38 additions & 0 deletions internal/ui/app_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -410,6 +410,44 @@ func TestAppEnterDoesNothingWithNonLetterLabel(t *testing.T) {
}
}

func TestAppEnterSelectsHighlightedVerticalOption(t *testing.T) {
t.Parallel()

app := NewApp("dev", "", "", nil)
responseCh := make(chan output.InputResponse, 1)

model, _ := app.Update(output.UserInputRequestEvent{
Prompt: "Update lstk to latest version?",
Options: []output.InputOption{{Key: "u", Label: "Update now [U]"}, {Key: "s", Label: "Skip this version [S]"}, {Key: "n", Label: "Never ask again [N]"}},
ResponseCh: responseCh,
Vertical: true,
})
app = model.(App)

model, _ = app.Update(tea.KeyMsg{Type: tea.KeyDown})
app = model.(App)

model, cmd := app.Update(tea.KeyMsg{Type: tea.KeyEnter})
app = model.(App)
if cmd == nil {
t.Fatal("expected response command when enter is pressed on vertical prompt")
}
cmd()

select {
case resp := <-responseCh:
if resp.SelectedKey != "s" {
t.Fatalf("expected s key, got %q", resp.SelectedKey)
}
case <-time.After(time.Second):
t.Fatal("timed out waiting for response on channel")
}

if app.inputPrompt.Visible() {
t.Fatal("expected input prompt to be hidden after response")
}
}

func TestAppAnyKeyOptionResolvesOnAnyKeypress(t *testing.T) {
t.Parallel()

Expand Down
53 changes: 45 additions & 8 deletions internal/ui/components/input_prompt.go
Original file line number Diff line number Diff line change
Expand Up @@ -8,19 +8,23 @@ import (
)

type InputPrompt struct {
prompt string
options []output.InputOption
visible bool
prompt string
options []output.InputOption
visible bool
selectedIndex int
vertical bool
}

func NewInputPrompt() InputPrompt {
return InputPrompt{}
}

func (p InputPrompt) Show(prompt string, options []output.InputOption) InputPrompt {
func (p InputPrompt) Show(prompt string, options []output.InputOption, vertical bool) InputPrompt {
p.prompt = prompt
p.options = options
p.visible = true
p.selectedIndex = 0
p.vertical = vertical
return p
}

Expand All @@ -33,20 +37,31 @@ func (p InputPrompt) Visible() bool {
return p.visible
}

func (p InputPrompt) SelectedIndex() int {
return p.selectedIndex
}

func (p InputPrompt) SetSelectedIndex(idx int) InputPrompt {
if idx >= 0 && idx < len(p.options) {
p.selectedIndex = idx
}
return p
}

func (p InputPrompt) View() string {
if !p.visible {
return ""
}

lines := strings.Split(p.prompt, "\n")
if p.vertical {
return p.viewVertical()
}

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

var sb strings.Builder

// "?" prefix in secondary color
sb.WriteString(styles.Secondary.Render("? "))

sb.WriteString(styles.Message.Render(firstLine))

if suffix := output.FormatPromptLabels(p.options); suffix != "" {
Expand All @@ -60,3 +75,25 @@ func (p InputPrompt) View() string {

return sb.String()
}

func (p InputPrompt) viewVertical() string {
var sb strings.Builder

if p.prompt != "" {
sb.WriteString(styles.Secondary.Render("? "))
sb.WriteString(styles.Message.Render(strings.TrimPrefix(p.prompt, "? ")))
sb.WriteString("\n")
}

for i, opt := range p.options {
if i == p.selectedIndex {
sb.WriteString(styles.NimboMid.Render("● " + opt.Label))
} else {
sb.WriteString(styles.Secondary.Render("○ " + opt.Label))
}
sb.WriteString("\n")
}

return sb.String()
}

8 changes: 7 additions & 1 deletion internal/ui/components/input_prompt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,26 +14,30 @@ func TestInputPromptView(t *testing.T) {
name string
prompt string
options []output.InputOption
vertical bool
contains []string
excludes []string
}{
{
name: "hidden returns empty",
prompt: "",
options: nil,
vertical: false,
contains: nil,
},
{
name: "no options",
prompt: "Continue?",
options: nil,
vertical: false,
contains: []string{"?", "Continue?"},
excludes: []string{"(", "["},
},
{
name: "single option shows parentheses",
prompt: "Continue?",
options: []output.InputOption{{Key: "enter", Label: "Press ENTER"}},
vertical: false,
contains: []string{"?", "Continue?", "(Press ENTER)"},
},
{
Expand All @@ -43,12 +47,14 @@ func TestInputPromptView(t *testing.T) {
{Key: "y", Label: "Y"},
{Key: "n", Label: "n"},
},
vertical: false,
contains: []string{"?", "Configure AWS profile?", "[Y/n]"},
},
{
name: "multi-line prompt renders trailing lines",
prompt: "First line\nSecond line\nThird line",
options: []output.InputOption{{Key: "y", Label: "Y"}},
vertical: false,
contains: []string{"?", "First line", "Second line", "Third line", "(Y)"},
},
}
Expand All @@ -67,7 +73,7 @@ func TestInputPromptView(t *testing.T) {
return
}

p = p.Show(tc.prompt, tc.options)
p = p.Show(tc.prompt, tc.options, tc.vertical)
view := p.View()

for _, s := range tc.contains {
Expand Down
1 change: 1 addition & 0 deletions internal/ui/components/message.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@ func RenderMessage(e output.MessageEvent) string {

func RenderWrappedMessage(e output.MessageEvent, width int) string {
prefixText, prefix := messagePrefix(e)

if prefixText == "" {
style := styles.Message
if e.Severity == output.SeveritySecondary {
Expand Down
16 changes: 12 additions & 4 deletions internal/update/github.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,22 +37,30 @@ func githubRequest(ctx context.Context, url, token string) (*http.Response, erro
return http.DefaultClient.Do(req)
}

func fetchLatestVersion(ctx context.Context, token string) (string, error) {
func fetchLatestRelease(ctx context.Context, token string) (*githubRelease, error) {
resp, err := githubRequest(ctx, latestReleaseURL, token)
if err != nil {
return "", err
return nil, err
}
defer func() { _ = resp.Body.Close() }()

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

var release githubRelease
if err := json.NewDecoder(resp.Body).Decode(&release); err != nil {
return "", err
return nil, err
}

return &release, nil
}

func fetchLatestVersion(ctx context.Context, token string) (string, error) {
release, err := fetchLatestRelease(ctx, token)
if err != nil {
return "", err
}
return release.TagName, nil
}

Expand Down
Loading