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
7 changes: 4 additions & 3 deletions cmd/root.go
Original file line number Diff line number Diff line change
Expand Up @@ -122,9 +122,10 @@ 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,
SkippedVersion: appConfig.CLI.UpdateSkippedVersion,
PersistSkipVersion: config.SetUpdateSkippedVersion,
}

if isInteractiveMode(cfg) {
Expand Down
120 changes: 114 additions & 6 deletions internal/config/config.go
Original file line number Diff line number Diff line change
@@ -1,22 +1,29 @@
package config

import (
"bufio"
_ "embed"
"errors"
"fmt"
"os"
"path/filepath"
"strings"

"github.com/spf13/viper"
)

//go:embed default_config.toml
var defaultConfigTemplate string

type CLIConfig struct {
UpdatePrompt bool `mapstructure:"update_prompt"`
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.

suggestion: as far as I understand, we only left this here for backward-compatibility. The new version will not let the users choose to never be prompted again for update from the UI. But we have this code here to make sure that users that have update_prompt set to true in their config, will indeed never be prompted again.
My suggestion is: can we remove this code completely and ignore if they have that setting? That would clean up the code quite a bit. That would also allow getting rid of the migration logic lines 229-231.

UpdateSkippedVersion string `mapstructure:"update_skipped_version"`
}

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 +34,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 @@ -109,18 +116,119 @@ func resolvedConfigPath() string {

func Set(key string, value any) error {
viper.Set(key, value)
return viper.WriteConfig()
return setInFile(viper.ConfigFileUsed(), key, value)
}

// setInFile updates a single key in the TOML config file without
// rewriting unrelated keys (avoids Viper dumping all defaults).
func setInFile(path, key string, value any) error {
// Split "cli.update_skipped_version" into section "cli" and field "update_skipped_version".
parts := strings.SplitN(key, ".", 2)
if len(parts) != 2 {
// Top-level keys: fall back to full rewrite.
return viper.WriteConfig()
}
section, field := parts[0], parts[1]

formatted := formatTOMLValue(value)
targetLine := field + " = " + formatted

data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("failed to read config file: %w", err)
}

var result []string
scanner := bufio.NewScanner(strings.NewReader(string(data)))
inSection := false
replaced := false
sectionHeader := "[" + section + "]"

for scanner.Scan() {
line := scanner.Text()
trimmed := strings.TrimSpace(line)

// Detect section headers.
if strings.HasPrefix(trimmed, "[") {
if trimmed == sectionHeader {
inSection = true
} else if inSection {
// Leaving our section without having replaced — insert before the new section.
if !replaced {
result = append(result, targetLine)
replaced = true
}
inSection = false
}
}

// Replace existing key in the target section.
if inSection && strings.HasPrefix(trimmed, field+" ") || inSection && strings.HasPrefix(trimmed, field+"=") {
result = append(result, targetLine)
replaced = true
continue
}

result = append(result, line)
}

// Section exists but key was not found — append to end of file (still in section).
if !replaced && inSection {
result = append(result, targetLine)
replaced = true
}

// Section doesn't exist at all — append section and key.
if !replaced {
if len(result) > 0 && strings.TrimSpace(result[len(result)-1]) != "" {
result = append(result, "")
}
result = append(result, sectionHeader)
result = append(result, targetLine)
}

output := strings.Join(result, "\n")
if !strings.HasSuffix(output, "\n") {
output += "\n"
}

return os.WriteFile(path, []byte(output), 0644)
}
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.

suggestion: can we use the go-toml/v2 dependency to achieve updating a single value, instead of writing that func from scratch? I think it would remove a lot of complexity/fragility here.


func formatTOMLValue(v any) string {
switch val := v.(type) {
case string:
return fmt.Sprintf("%q", val)
case bool:
if val {
return "true"
}
return "false"
default:
return fmt.Sprintf("%v", val)
}
}

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

func SetUpdateSkippedVersion(version string) error {
return Set("cli.update_skipped_version", version)
}

func GetUpdateSkippedVersion() string {
return viper.GetString("cli.update_skipped_version")
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 is not used. We don't really need getters for config, since we access directly the values from the CLIConfig struct.

}

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 {
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
48 changes: 44 additions & 4 deletions 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)
}
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,10 +298,36 @@ func (a *App) flushBufferedLines() {
a.bufferedLines = nil
}

func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) string {
formatted := output.FormatPrompt(req.Prompt, req.Options)
firstLine := strings.Split(formatted, "\n")[0]
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
}
}
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 {
selected := selectedKey
hasLabels := false
for _, opt := range req.Options {
Expand All @@ -310,6 +339,17 @@ func formatResolvedInput(req output.UserInputRequestEvent, selectedKey string) s
}
}

if req.Vertical {
firstLine := strings.Split(req.Prompt, "\n")[0]
if selected == "" || !hasLabels || selectedKey == "any" {
return firstLine
}
return fmt.Sprintf("%s %s", firstLine, selected)
}

formatted := output.FormatPrompt(req.Prompt, req.Options)
firstLine := strings.Split(formatted, "\n")[0]

if selected == "" || !hasLabels || selectedKey == "any" {
return firstLine
}
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()
}

Loading