diff --git a/README.md b/README.md index 860d7f6..60d0bc9 100644 --- a/README.md +++ b/README.md @@ -256,7 +256,7 @@ overlay listing every binding in the current context. Enter edit field / open enum popup / confirm Space toggle optional parameter on/off (required fields show a hint) Esc close popup; from list, cancel and save draft -/ filter by parameter name and help text +/ filter by parameter name and help text; inside a popup list, search it (Esc ends the search) Tab Shift-Tab cycle list → Done → Cancel → list g open a popup to set a shell variable (`name=value`, or just `name` to re-declare the current session value) — the widget evals the line in your shell after the form closes, so the var persists until you unset it; the var also appears in the Ctrl+G picker for the same session a show all collapsed parameters @@ -322,6 +322,33 @@ Use `v` to preview what `az` will actually receive: Pressing `v` while in text-edit mode types `v` into the input; press `Esc` first to leave the field, then `v` to cycle. +## Live values + +Parameters that name something you already have get a list of the real +resources on `Enter`, fetched with read-only `az … list` calls: + +- `--resource-group` in every command, and `--name` of `az group + show/delete/update/…` +- `--name` when a command acts on an existing resource — `show`, + `delete`, `update`, `start`, `stop`, `scale`, `get-credentials`, … of + VMs, AKS clusters, registries, key vaults, storage accounts, web and + function apps, App Service plans, SQL/PostgreSQL servers, Cosmos DB, + Redis, virtual networks, NSGs, public IPs, load balancers, gateways, + Bastion hosts, Event Hubs/Service Bus namespaces and Log Analytics + workspaces. Never on `create`, where the name is new. +- references to another resource: `--vault-name`, `--vnet-name`, + `--vm-name`, `--account-name` (storage commands), `--server` (`sql db`), + `--plan` (web and function apps), `--cluster-name` (`aks`) +- anything the CLI's own help marks with `Values from:` (locations, VM + sizes, Kubernetes versions) + +Resource lists are narrowed to the form's `--resource-group` once it is +set, follow its `--subscription`, and load in the background as soon as +the form opens. In a list, `/` searches; the first row, *type a +value…*, switches to free text (a search that found nothing is carried +over, handy for a name that does not exist yet). If `az` is signed out +the field says so and you type the value as usual. + ## Privacy `azform` runs entirely on your machine and holds nothing on a server. @@ -338,6 +365,12 @@ itself. The only literal values persisted are enum parameters with a closed value set (such as `--sku Standard_LRS`), where the alternative would be to re-pick from the list each time. +The one exception is the short-lived cache behind *Live values*: the +names of the resources a list showed (resource groups, VMs, …) are kept +in the cache directory for three minutes, so reopening the form does not +wait for `az` again. It is private to your user, holds names only, and +is ignored as soon as `az login`, `az logout` or `az account set` runs. + `azform` does not handle Azure authentication in any form. Tokens, device-code flows, and credential caches live entirely inside the `az` binary the form calls. If `az` is signed out, the form degrades diff --git a/internal/ui/enum.go b/internal/ui/enum.go index e7b142e..12d8fe4 100644 --- a/internal/ui/enum.go +++ b/internal/ui/enum.go @@ -8,7 +8,12 @@ import ( ) // EnumSelectedMsg is sent when the user picks a value from the enum popup. -type EnumSelectedMsg struct{ Value string } +// Query is the popup's search text at the time, so picking the free-text +// row can hand what was typed on to the editor. +type EnumSelectedMsg struct { + Value string + Query string +} // EnumCancelledMsg is sent when the user presses Esc in the enum popup. type EnumCancelledMsg struct{} @@ -17,12 +22,21 @@ var ( enumCursorStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("12")) ) -// EnumModel is a bubbletea component for picking from a closed list of choices. -// It is embedded in Form when the user opens an enum or bool field. +// EnumModel is a bubbletea component for picking from a list of choices. +// It is embedded in Form for enum/bool fields and for fetched values. +// +// "/" starts a case-insensitive substring search, the same key as the +// form's own filter. While searching, typed characters (j, k and q +// included) go into the query and the arrow keys move; Esc ends the +// search before a second Esc closes the popup. A leading manualEntryChoice +// row is pinned: it stays visible whatever the query. type EnumModel struct { - choices []string - cursor int - width int + all []string // every choice, pinned row included + choices []string // the rows currently shown (all, narrowed by query) + cursor int // index into choices + width int + query string + searching bool } // NewEnum creates an EnumModel pre-positioned at current (empty string → index 0). @@ -34,13 +48,13 @@ func NewEnum(choices []string, current string, width int) EnumModel { break } } - return EnumModel{choices: choices, cursor: cursor, width: width} + return EnumModel{all: choices, choices: choices, cursor: cursor, width: width} } // Init implements tea.Model. func (m EnumModel) Init() tea.Cmd { return nil } -// Update handles ↑/↓ navigation, Enter (select), and Esc/q (cancel). +// Update handles navigation, search, Enter (select) and Esc/q (cancel). // Esc and q emit EnumCancelledMsg; Enter emits EnumSelectedMsg. Neither key // propagates out of the enum popup (spec 6.6 — Esc must not close the form). func (m EnumModel) Update(msg tea.Msg) (EnumModel, tea.Cmd) { @@ -48,24 +62,95 @@ func (m EnumModel) Update(msg tea.Msg) (EnumModel, tea.Cmd) { if !ok { return m, nil } + if m.searching { + return m.updateSearch(km) + } switch km.String() { case "up", "k": - if m.cursor > 0 { - m.cursor-- - } + m.move(-1) case "down", "j": - if m.cursor < len(m.choices)-1 { - m.cursor++ - } + m.move(+1) + case "/": + m.searching = true case "enter": - val := m.choices[m.cursor] - return m, func() tea.Msg { return EnumSelectedMsg{Value: val} } + return m, m.selected() case "esc", "q": return m, func() tea.Msg { return EnumCancelledMsg{} } } return m, nil } +func (m EnumModel) updateSearch(km tea.KeyMsg) (EnumModel, tea.Cmd) { + switch km.Type { + case tea.KeyUp, tea.KeyCtrlP: + m.move(-1) + case tea.KeyDown, tea.KeyCtrlN: + m.move(+1) + case tea.KeyEnter: + return m, m.selected() + case tea.KeyEsc: + m.searching = false + m.setQuery("") + case tea.KeyBackspace: + if r := []rune(m.query); len(r) > 0 { + m.setQuery(string(r[:len(r)-1])) + } + case tea.KeyRunes, tea.KeySpace: + m.setQuery(m.query + string(km.Runes)) + } + return m, nil +} + +func (m *EnumModel) move(delta int) { + if next := m.cursor + delta; next >= 0 && next < len(m.choices) { + m.cursor = next + } +} + +func (m EnumModel) selected() tea.Cmd { + if len(m.choices) == 0 { + return nil + } + val, query := m.choices[m.cursor], m.query + return func() tea.Msg { return EnumSelectedMsg{Value: val, Query: query} } +} + +// setQuery narrows choices to the case-insensitive matches of q and puts +// the cursor on the first real match (after the pinned row, if any), so +// Enter right after typing picks what the user was looking for. +func (m *EnumModel) setQuery(q string) { + m.query = q + needle := strings.ToLower(q) + m.choices = m.choices[:0:0] + firstMatch := -1 + for i, c := range m.all { + pinned := i == 0 && c == manualEntryChoice + if pinned || strings.Contains(strings.ToLower(c), needle) { + if !pinned && firstMatch < 0 { + firstMatch = len(m.choices) + } + m.choices = append(m.choices, c) + } + } + m.cursor = 0 + if firstMatch >= 0 { + m.cursor = firstMatch + } +} + +// Header is the extra popup row above the choices: the live query while +// searching, a "/ filter" hint when the list is long enough to scroll, +// else "". +func (m EnumModel) Header() string { + switch { + case m.searching || m.query != "": + return "/ " + m.query + "▏" + case len(m.all) > maxPopupItems: + return "/ filter" + } + return "" +} + // View renders the choice list. The selected row is highlighted. func (m EnumModel) View() string { var sb strings.Builder diff --git a/internal/ui/enum_search_test.go b/internal/ui/enum_search_test.go new file mode 100644 index 0000000..e1e19dd --- /dev/null +++ b/internal/ui/enum_search_test.go @@ -0,0 +1,133 @@ +package ui + +import ( + "strings" + "testing" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/someson/azform/internal/metadata" +) + +func keys(m EnumModel, ks ...tea.KeyMsg) (EnumModel, tea.Cmd) { + var cmd tea.Cmd + for _, k := range ks { + m, cmd = m.Update(k) + } + return m, cmd +} + +func runes(s string) tea.KeyMsg { return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} } + +var ( + keyEnter = tea.KeyMsg{Type: tea.KeyEnter} + keyEsc = tea.KeyMsg{Type: tea.KeyEsc} + keyDown = tea.KeyMsg{Type: tea.KeyDown} + keyBack = tea.KeyMsg{Type: tea.KeyBackspace} +) + +func groups() []string { + return []string{manualEntryChoice, "dev-jobs", "prod-weu", "prod-neu", "qa-rg", "shared-kv", "test-01", "test-02", "zeta"} +} + +func TestEnumSearchNarrowsAndKeepsPinnedRow(t *testing.T) { + m := NewEnum(groups(), "", 40) + if h := m.Header(); h != "/ filter" { + t.Errorf("long list header = %q, want the / filter hint", h) + } + // j and q are text while searching, not navigation / cancel. + m, _ = keys(m, runes("/"), runes("j"), runes("o")) + if got := strings.Join(m.choices, ","); got != manualEntryChoice+",dev-jobs" { + t.Fatalf("choices for %q = %q", m.query, got) + } + if m.choices[m.cursor] != "dev-jobs" { + t.Errorf("cursor on %q, want the first match", m.choices[m.cursor]) + } + if h := m.Header(); h != "/ jo▏" { + t.Errorf("header = %q", h) + } + m, _ = keys(m, keyBack, keyBack, runes("PROD")) + if got := strings.Join(m.choices[1:], ","); got != "prod-weu,prod-neu" { + t.Errorf("case-insensitive match = %q", got) + } + m, cmd := keys(m, keyDown, keyEnter) + if sel := cmd().(EnumSelectedMsg); sel.Value != "prod-neu" || sel.Query != "PROD" { + t.Errorf("selected %+v", sel) + } +} + +func TestEnumSearchEscClearsBeforeClosing(t *testing.T) { + m := NewEnum(groups(), "", 40) + m, cmd := keys(m, runes("/"), runes("q"), keyEsc) + if cmd != nil { + t.Fatalf("first Esc must end the search, not close the popup") + } + if m.searching || m.query != "" || len(m.choices) != len(groups()) { + t.Errorf("search not reset: searching=%v query=%q n=%d", m.searching, m.query, len(m.choices)) + } + _, cmd = keys(m, keyEsc) + if _, ok := cmd().(EnumCancelledMsg); !ok { + t.Errorf("second Esc should cancel") + } +} + +func TestEnumSearchWithoutPinnedRow(t *testing.T) { + m := NewEnum([]string{"Standard_LRS", "Premium_LRS"}, "", 40) + if h := m.Header(); h != "" { + t.Errorf("short list header = %q, want none", h) + } + m, cmd := keys(m, runes("/"), runes("nothing"), keyEnter) + if len(m.choices) != 0 || cmd != nil { + t.Errorf("empty result: choices=%v cmd=%v (Enter must be a no-op)", m.choices, cmd != nil) + } + lines := buildPopupLines(m.choices, m.cursor, m.Header()) + joined := stripANSI(strings.Join(lines, "\n")) + if !strings.Contains(joined, "/ nothing▏") || !strings.Contains(joined, "(no matches)") { + t.Errorf("empty-result popup:\n%s", joined) + } + if h := enumPopupHeight(m.choices, m.Header()); h != len(lines) { + t.Errorf("height %d != rendered %d lines", h, len(lines)) + } +} + +func TestPopupHeaderRowRendered(t *testing.T) { + lines := buildPopupLines(groups(), 0, "/ filter") + if len(lines) != maxPopupItems+3 { + t.Fatalf("got %d lines, want borders + header + %d items", len(lines), maxPopupItems) + } + if !strings.Contains(stripANSI(lines[1]), "/ filter") { + t.Errorf("header row = %q", stripANSI(lines[1])) + } + w := len([]rune(stripANSI(lines[0]))) + for i, ln := range lines { + if got := len([]rune(stripANSI(ln))); got != w { + t.Errorf("line %d width %d, want %d: %q", i, got, w, stripANSI(ln)) + } + } +} + +// A search that finds nothing is usually a new name: picking the free-text +// row opens the editor with the query already typed. +func TestManualEntrySeededWithQuery(t *testing.T) { + f := NewForm("vm show", "/tmp/out.txt", t.TempDir(), "test", nil) + m, _ := f.Update(MetadataLoadedMsg{Params: []metadata.Parameter{ + {Name: "--resource-group", TakesValue: true, ValueKind: metadata.ValueKindString}, + }}) + f = m.(Form) + rg := f.FieldIndex("--resource-group") + f.fields[rg].FetchState = FetchLoaded + f.fields[rg].FetchedChoices = []string{"dev-rg", "prod-rg"} + m, _ = f.Update(keyEnter) + f = m.(Form) + for _, k := range []tea.KeyMsg{runes("/"), runes("new-rg")} { + m, _ = f.Update(k) + f = m.(Form) + } + m, cmd := f.Update(keyEnter) + f = m.(Form) + m, _ = f.Update(cmd()) + f = m.(Form) + if f.mode != FormModeEdit || f.textInput.Value() != "new-rg" { + t.Errorf("mode=%v input=%q, want editor seeded with new-rg", f.mode, f.textInput.Value()) + } +} diff --git a/internal/ui/fetch.go b/internal/ui/fetch.go index 56a39ab..e957e13 100644 --- a/internal/ui/fetch.go +++ b/internal/ui/fetch.go @@ -3,14 +3,21 @@ package ui import ( "bytes" "context" + "crypto/sha256" + "encoding/hex" "encoding/json" + "errors" "fmt" + "os" "os/exec" + "path/filepath" "sort" "strings" "time" tea "github.com/charmbracelet/bubbletea" + + "github.com/someson/azform/internal/metadata" ) // fetchTimeout caps the subprocess invocation. 30 s matches the spec's 10 s @@ -24,40 +31,267 @@ type FieldFetchedMsg struct { FieldIdx int Choices []string Err error + // Gen is the field's FetchGen when the fetch started; a completion + // from before an invalidation carries an older value and is dropped. + Gen int +} + +// valuesCacheTTL bounds how long fetched values are reused across form +// invocations. Short on purpose: the lists are the user's live resources, +// and a group created a few minutes ago should show up. The picker's +// free-text row covers anything newer. +const valuesCacheTTL = 3 * time.Minute + +// fetchSpec says what to run for a field's values and how to present them. +type fetchSpec struct { + command string // az command line without the leading "az" + sorted bool // sort choices; az returns resource lists in no useful order + cacheDir string // "" disables the on-disk values cache + gen int // the field's FetchGen, echoed in FieldFetchedMsg } -// fetchField runs `az --output json`, parses the result, and +// runAz runs az with args; a package variable so tests can stub the process. +var runAz = func(ctx context.Context, args ...string) ([]byte, []byte, error) { + cmd := exec.CommandContext(ctx, "az", args...) + cmd.Env = append(cmd.Environ(), "AZURE_CORE_NO_COLOR=1") + var stderr bytes.Buffer + cmd.Stderr = &stderr + out, err := cmd.Output() + return out, stderr.Bytes(), err +} + +// fetchField runs `az --output json`, parses the result, and // returns the choices via FieldFetchedMsg. The command is read-only — the // spec forbids `az` invocations that mutate state (§5.4, D6). -func fetchField(fieldIdx int, valuesFrom string) tea.Cmd { +func fetchField(fieldIdx int, spec fetchSpec) tea.Cmd { return func() tea.Msg { + if choices, ok := loadCachedValues(spec.cacheDir, spec.command, time.Now()); ok { + return FieldFetchedMsg{FieldIdx: fieldIdx, Gen: spec.gen, Choices: choices} + } ctx, cancel := context.WithTimeout(context.Background(), fetchTimeout) defer cancel() - args := buildFetchArgs(valuesFrom) - cmd := exec.CommandContext(ctx, "az", args...) - cmd.Env = append(cmd.Environ(), "AZURE_CORE_NO_COLOR=1") - var stderr bytes.Buffer - cmd.Stderr = &stderr - out, err := cmd.Output() + out, stderr, err := runAz(ctx, buildFetchArgs(spec.command)...) if err != nil { - return FieldFetchedMsg{ - FieldIdx: fieldIdx, - Err: fmt.Errorf("az %s: %s", valuesFrom, oneLine(stderr.String())), - } + return FieldFetchedMsg{FieldIdx: fieldIdx, Gen: spec.gen, Err: fetchError(spec.command, stderr, err)} } choices, perr := parseFetchedValues(out) if perr != nil { - return FieldFetchedMsg{ - FieldIdx: fieldIdx, - Err: perr, - } + return FieldFetchedMsg{FieldIdx: fieldIdx, Gen: spec.gen, Err: perr} + } + if spec.sorted { + sort.Strings(choices) + } + saveCachedValues(spec.cacheDir, spec.command, choices, time.Now()) + return FieldFetchedMsg{FieldIdx: fieldIdx, Gen: spec.gen, Choices: choices} + } +} + +// fetchError turns a failed az run into the one-line message shown next to +// the field. The two failures every new user hits get a plain explanation +// instead of az's multi-line stderr. +func fetchError(command string, stderr []byte, err error) error { + msg := string(stderr) + lower := strings.ToLower(msg) + switch { + case errors.Is(err, exec.ErrNotFound): + return errors.New("az not found on PATH — type a value") + case strings.Contains(lower, "az login"): + return errors.New("not signed in (run az login) — type a value") + case strings.TrimSpace(msg) == "": + msg = err.Error() + } + return fmt.Errorf("az %s: %s", command, oneLine(msg)) +} + +// implicitSource returns the values command for params whose valid values +// are the user's existing resources but whose help text carries no +// `Values from:` hint. It is deliberately narrow: listing existing names +// for a param that names a resource to be *created* would be wrong, so only +// params that always refer to something that exists are covered. +func implicitSource(command string, p metadata.Parameter) string { + switch p.Name { + case "--resource-group": + // `az group create` names its new group --name (with + // --resource-group only as an alias), so this never lands there. + return "az group list" + case "--name": + return existingNameSource(command) + } + for _, ref := range resourceRefs { + if p.Name == ref.param && strings.HasPrefix(command+" ", ref.within) { + return "az " + ref.list } - return FieldFetchedMsg{ - FieldIdx: fieldIdx, - Choices: choices, + } + return "" +} + +// existingNameSource returns the list command for the --name of a command +// that acts on an existing resource: ` ` with group in +// resourceLists and verb in existingVerbs. Creation verbs are never in +// existingVerbs, and sub-resources (`network vnet subnet show`) fall +// outside the table because their group is not a key. +func existingNameSource(command string) string { + for group, list := range resourceLists { + verb, ok := strings.CutPrefix(command, group+" ") + if ok && existingVerbs[verb] { + return "az " + list } } + return "" +} + +// resourceLists maps a command group to the command listing its resources. +// Every list here accepts an optional --resource-group (resourceScoped). +var resourceLists = map[string]string{ + "group": "group list", + "vm": "vm list", + "vmss": "vmss list", + "aks": "aks list", + "acr": "acr list", + "keyvault": "keyvault list", + "storage account": "storage account list", + "webapp": "webapp list", + "functionapp": "functionapp list", + "appservice plan": "appservice plan list", + "cosmosdb": "cosmosdb list", + "redis": "redis list", + "sql server": "sql server list", + "postgres flexible-server": "postgres flexible-server list", + "network vnet": "network vnet list", + "network nsg": "network nsg list", + "network public-ip": "network public-ip list", + "network lb": "network lb list", + "network application-gateway": "network application-gateway list", + "network bastion": "network bastion list", + "eventhubs namespace": "eventhubs namespace list", + "servicebus namespace": "servicebus namespace list", + "monitor log-analytics workspace": "monitor log-analytics workspace list", +} + +// existingVerbs are the verbs whose --name must name an existing resource. +var existingVerbs = map[string]bool{ + "show": true, "delete": true, "update": true, "wait": true, "export": true, + "start": true, "stop": true, "restart": true, "deallocate": true, + "redeploy": true, "resize": true, "get-instance-view": true, + "scale": true, "upgrade": true, "get-credentials": true, "get-upgrades": true, + "login": true, "browse": true, "show-connection-string": true, + "keys list": true, "keys renew": true, "ssh": true, "tunnel": true, "rdp": true, +} + +// resourceRefs are params that point at an existing resource of another +// type, whatever the verb. within limits a ref to one command family where +// the param name is ambiguous elsewhere (--account-name is a Cosmos DB +// account under `cosmosdb`, a storage account under `storage`). +var resourceRefs = []struct { + param, within, list string +}{ + {"--vm-name", "", "vm list"}, + {"--vault-name", "", "keyvault list"}, + {"--vnet-name", "", "network vnet list"}, + {"--account-name", "storage ", "storage account list"}, + {"--server", "sql db ", "sql server list"}, + {"--plan", "webapp ", "appservice plan list"}, + {"--plan", "functionapp ", "appservice plan list"}, + {"--cluster-name", "aks ", "aks list"}, +} + +// resourceScoped reports whether list is one of the resource lists above +// that accepts --resource-group to narrow it (every one but group list). +func resourceScoped(list string) bool { + if list == "group list" { + return false + } + for _, l := range resourceLists { + if l == list { + return true + } + } + return false +} + +// valuesCachePath maps a fetch command to its cache file. +func valuesCachePath(cacheDir, command string) string { + sum := sha256.Sum256([]byte(command)) + return filepath.Join(cacheDir, "values", hex.EncodeToString(sum[:8])+".json") +} + +type cachedValues struct { + Command string `json:"command"` + At time.Time `json:"at"` + Profile time.Time `json:"profile"` // azureProfile.json mtime when fetched + Choices []string `json:"choices"` +} + +// profileStamp returns the mtime of the Azure CLI profile. `az login`, +// `az logout` and `az account set` rewrite it, and any of them can change +// which subscription (and so which resources) a plain `az group list` +// sees; cached values from before the change are not reused. +var profileStamp = func() time.Time { + dir := os.Getenv("AZURE_CONFIG_DIR") + if dir == "" { + home, err := os.UserHomeDir() + if err != nil { + return time.Time{} + } + dir = filepath.Join(home, ".azure") + } + info, err := os.Stat(filepath.Join(dir, "azureProfile.json")) + if err != nil { + return time.Time{} + } + return info.ModTime().UTC() +} + +func loadCachedValues(cacheDir, command string, now time.Time) ([]string, bool) { + if cacheDir == "" { + return nil, false + } + data, err := os.ReadFile(valuesCachePath(cacheDir, command)) + if err != nil { + return nil, false + } + var c cachedValues + if json.Unmarshal(data, &c) != nil || c.Command != command || len(c.Choices) == 0 { + return nil, false + } + if !c.Profile.Equal(profileStamp()) { + return nil, false + } + if age := now.Sub(c.At); age < 0 || age > valuesCacheTTL { + return nil, false + } + return c.Choices, true +} + +// saveCachedValues is best-effort: a failed write only costs the next +// invocation an az call. +func saveCachedValues(cacheDir, command string, choices []string, now time.Time) { + if cacheDir == "" { + return + } + path := valuesCachePath(cacheDir, command) + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + data, err := json.Marshal(cachedValues{Command: command, At: now.UTC(), Profile: profileStamp(), Choices: choices}) + if err != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".values-*.tmp") + if err != nil { + return + } + name := tmp.Name() + defer func() { _ = os.Remove(name) }() + if _, err := tmp.Write(data); err != nil { + _ = tmp.Close() + return + } + if tmp.Close() != nil { + return + } + _ = os.Rename(name, path) } // buildFetchArgs splits a `ValuesFrom` hint into shell-style fields, drops @@ -130,6 +364,18 @@ func fetchCommand(valuesFrom string, lookup func(param string) string) (cmd stri args = append(args, param, v) } } + // Resource lists narrow to the form's group when one is set; without it + // they list the whole subscription, which is still useful. + if resourceScoped(strings.Join(args, " ")) { + if rg := lookup("--resource-group"); rg != "" { + args = append(args, "--resource-group", rg) + } + } + // The values must come from the subscription the command will run + // against, not the CLI's default one. + if sub := lookup("--subscription"); sub != "" { + args = append(args, "--subscription", sub) + } return strings.Join(args, " "), true } @@ -141,7 +387,12 @@ func fetchContextParams(valuesFrom string) []string { if len(args) > 0 && args[0] == "az" { args = args[1:] } - return fetchContext[strings.Join(args, " ")] + path := strings.Join(args, " ") + deps := append([]string{"--subscription"}, fetchContext[path]...) + if resourceScoped(path) { + deps = append(deps, "--resource-group") + } + return deps } // choiceKeys are the object fields tried, in order, as an item's value. @@ -150,8 +401,7 @@ var choiceKeys = []string{"name", "displayName", "version", "kubernetesVersion"} // parseFetchedValues extracts the choice list from a `az ... --output json` // response. Heuristic (spec §4.5): in an array of plain strings each string -// is a value; -// for an object, the first of choiceKeys that is a non-empty string, else +// is a value; for an object, the first of choiceKeys that is a non-empty string, else // the first non-empty string field in key order. Duplicates are dropped. func parseFetchedValues(raw []byte) ([]string, error) { var v any diff --git a/internal/ui/field.go b/internal/ui/field.go index 9101e72..58c8fe3 100644 --- a/internal/ui/field.go +++ b/internal/ui/field.go @@ -77,6 +77,7 @@ type Field struct { FetchError string // populated when FetchState == FetchError FetchStartedAt time.Time // when Loading began; used by the 3 s / 10 s ticks FetchSpinnerShow bool // gated by the 150 ms visibility tick; suppresses flicker on fast loads + FetchGen int // bumped when an in-flight fetch is superseded; see FieldFetchedMsg.Gen } // ToRenderValue returns the value and whether it is a var ref (for render.Build). diff --git a/internal/ui/handlers.go b/internal/ui/handlers.go index f17c5b3..ff42ef0 100644 --- a/internal/ui/handlers.go +++ b/internal/ui/handlers.go @@ -49,10 +49,10 @@ func (m Form) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if m.textInput.Value() != "" { m.fields[m.editIdx].Enabled = true } - m.invalidateDependentFetches(m.fields[m.editIdx].Param.Name) + refetch := m.invalidateDependentFetches(m.fields[m.editIdx].Param.Name) m.recomputeFindings(nil) m.mode = FormModeList - return m, nil + return m, refetch default: var cmd tea.Cmd m.textInput, cmd = m.textInput.Update(msg) @@ -75,6 +75,11 @@ func (m Form) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil } m.mode = FormModeList + // Filtering moves focus without the arrow keys, so it must + // start the focused field's fetch the way a move does. + if idx := m.fieldAt(m.cursor); idx >= 0 { + return m, m.maybeFetchField(idx) + } return m, nil default: var cmd tea.Cmd diff --git a/internal/ui/implicit_values_test.go b/internal/ui/implicit_values_test.go new file mode 100644 index 0000000..b219926 --- /dev/null +++ b/internal/ui/implicit_values_test.go @@ -0,0 +1,357 @@ +package ui + +import ( + "context" + "errors" + "os" + "os/exec" + "strings" + "testing" + "time" + + tea "github.com/charmbracelet/bubbletea" + + "github.com/someson/azform/internal/metadata" +) + +// stubAz replaces the az subprocess for one test and records every call. +func stubAz(t *testing.T, fn func(args []string) ([]byte, []byte, error)) *[]string { + t.Helper() + var calls []string + prev := runAz + runAz = func(_ context.Context, args ...string) ([]byte, []byte, error) { + calls = append(calls, strings.Join(args, " ")) + return fn(args) + } + t.Cleanup(func() { runAz = prev }) + return &calls +} + +func TestImplicitSource(t *testing.T) { + rg := metadata.Parameter{Name: "--resource-group", Aliases: []string{"-g"}} + name := metadata.Parameter{Name: "--name", Aliases: []string{"--resource-group", "-g", "-n"}} + cases := []struct { + command string + p metadata.Parameter + want string + }{ + {"vm create", rg, "az group list"}, + {"storage account show", rg, "az group list"}, + {"group show", name, "az group list"}, + {"group delete", name, "az group list"}, + // A new group is being named here: listing existing ones is wrong. + {"group create", name, ""}, + {"vm create", metadata.Parameter{Name: "--location"}, ""}, + + // --name of an existing resource. + {"vm show", metadata.Parameter{Name: "--name"}, "az vm list"}, + {"vm start", metadata.Parameter{Name: "--name"}, "az vm list"}, + {"storage account keys list", metadata.Parameter{Name: "--name"}, "az storage account list"}, + {"aks get-credentials", metadata.Parameter{Name: "--name"}, "az aks list"}, + {"network bastion ssh", metadata.Parameter{Name: "--name"}, "az network bastion list"}, + // ... but never where --name is being created or is a child. + {"vm create", metadata.Parameter{Name: "--name"}, ""}, + {"storage container create", metadata.Parameter{Name: "--name"}, ""}, + {"keyvault secret set", metadata.Parameter{Name: "--name"}, ""}, + {"network vnet subnet show", metadata.Parameter{Name: "--name"}, ""}, + {"sql db show", metadata.Parameter{Name: "--name"}, ""}, + + // References to another existing resource. + {"keyvault secret set", metadata.Parameter{Name: "--vault-name"}, "az keyvault list"}, + {"network vnet subnet create", metadata.Parameter{Name: "--vnet-name"}, "az network vnet list"}, + {"storage container create", metadata.Parameter{Name: "--account-name"}, "az storage account list"}, + {"sql db create", metadata.Parameter{Name: "--server"}, "az sql server list"}, + {"webapp create", metadata.Parameter{Name: "--plan"}, "az appservice plan list"}, + // Same param name, different meaning outside its family. + {"cosmosdb sql database create", metadata.Parameter{Name: "--account-name"}, ""}, + {"postgres flexible-server create", metadata.Parameter{Name: "--server"}, ""}, + } + for _, tc := range cases { + if got := implicitSource(tc.command, tc.p); got != tc.want { + t.Errorf("implicitSource(%q, %s) = %q, want %q", tc.command, tc.p.Name, got, tc.want) + } + } +} + +func TestFetchCommandPassesSubscription(t *testing.T) { + lookup := func(p string) string { + if p == "--subscription" { + return "sub-123" + } + return "" + } + got, ok := fetchCommand("az group list", lookup) + if !ok || got != "group list --subscription sub-123" { + t.Errorf("fetchCommand = %q, %v", got, ok) + } + if deps := fetchContextParams("az group list"); len(deps) != 1 || deps[0] != "--subscription" { + t.Errorf("fetchContextParams = %v, want [--subscription]", deps) + } +} + +// Resource lists narrow to the form's group when one is set, and a change +// of group invalidates them. +func TestResourceListScopedByGroup(t *testing.T) { + values := map[string]string{} + lookup := func(p string) string { return values[p] } + if got, _ := fetchCommand("az vm list", lookup); got != "vm list" { + t.Errorf("without group: %q", got) + } + values["--resource-group"] = "prod-rg" + if got, _ := fetchCommand("az vm list", lookup); got != "vm list --resource-group prod-rg" { + t.Errorf("with group: %q", got) + } + if got, _ := fetchCommand("az group list", lookup); got != "group list" { + t.Errorf("group list must not be scoped by a group: %q", got) + } + deps := strings.Join(fetchContextParams("az vm list"), ",") + if deps != "--subscription,--resource-group" { + t.Errorf("vm list deps = %q", deps) + } +} + +// vm show: the groups and, scoped to the chosen group, the VMs. +func TestVMNamesFollowResourceGroup(t *testing.T) { + calls := stubAz(t, func(args []string) ([]byte, []byte, error) { + if strings.Join(args[:2], " ") == "vm list" { + return []byte(`[{"name":"web-2"},{"name":"web-1"}]`), nil, nil + } + return []byte(`[{"name":"prod-rg"}]`), nil, nil + }) + f := NewForm("vm show", "/tmp/out.txt", t.TempDir(), "test", nil) + m, cmd := f.Update(MetadataLoadedMsg{Params: []metadata.Parameter{ + {Name: "--name", TakesValue: true, ValueKind: metadata.ValueKindString}, + {Name: "--resource-group", TakesValue: true, ValueKind: metadata.ValueKindString}, + }}) + f = m.(Form) + for _, msg := range runCmd(cmd) { + if fm, ok := msg.(FieldFetchedMsg); ok { + m, _ = f.Update(fm) + f = m.(Form) + } + } + name := f.FieldIndex("--name") + if got := strings.Join(f.fields[name].FetchedChoices, ","); got != "web-1,web-2" { + t.Errorf("vm names = %q", got) + } + // Choosing a group drops the subscription-wide list and lists that + // group's VMs right away. + rg := f.FieldIndex("--resource-group") + f.fields[rg].Value = "prod-rg" + f.fields[rg].Enabled = true + refetch := f.invalidateDependentFetches("--resource-group") + if f.fields[name].FetchState != FetchLoading || f.fields[name].FetchedChoices != nil { + t.Fatalf("vm names not refetched on group change: state=%v", f.fields[name].FetchState) + } + runCmd(refetch) + if last := (*calls)[len(*calls)-1]; last != "vm list --resource-group prod-rg --output json" { + t.Errorf("last az call = %q", last) + } +} + +// A fetch started before its context changed must not overwrite the list +// fetched for the new context. +func TestSupersededFetchResultIgnored(t *testing.T) { + stubAz(t, func([]string) ([]byte, []byte, error) { return []byte(`[{"name":"x"}]`), nil, nil }) + f := NewForm("vm show", "/tmp/out.txt", t.TempDir(), "test", nil) + m, _ := f.Update(MetadataLoadedMsg{Params: []metadata.Parameter{ + {Name: "--name", TakesValue: true, ValueKind: metadata.ValueKindString}, + {Name: "--resource-group", TakesValue: true, ValueKind: metadata.ValueKindString}, + }}) + f = m.(Form) + name := f.FieldIndex("--name") + if f.fields[name].FetchState != FetchLoading { + t.Fatalf("precondition: vm names should be prefetching") + } + old := f.fields[name].FetchGen + f.invalidateDependentFetches("--resource-group") + if f.fields[name].FetchGen == old { + t.Fatalf("in-flight fetch not superseded") + } + m, _ = f.Update(FieldFetchedMsg{FieldIdx: name, Choices: []string{"stale-vm"}, Gen: old}) + f = m.(Form) + if f.fields[name].FetchState != FetchLoading || f.fields[name].FetchedChoices != nil { + t.Errorf("stale result applied: state=%v choices=%v", f.fields[name].FetchState, f.fields[name].FetchedChoices) + } + m, _ = f.Update(FieldFetchedMsg{FieldIdx: name, Choices: []string{"fresh-vm"}, Gen: f.fields[name].FetchGen}) + f = m.(Form) + if got := strings.Join(f.fields[name].FetchedChoices, ","); got != "fresh-vm" { + t.Errorf("current result not applied: %q", got) + } +} + +func TestFetchErrorMessages(t *testing.T) { + if err := fetchError("group list", nil, exec.ErrNotFound); !strings.Contains(err.Error(), "az not found") { + t.Errorf("missing az: %v", err) + } + stderr := []byte("ERROR: Please run 'az login' to setup account.\n") + if err := fetchError("group list", stderr, errors.New("exit status 1")); !strings.Contains(err.Error(), "not signed in") { + t.Errorf("not logged in: %v", err) + } + if err := fetchError("group list", []byte("ERROR: boom\nmore"), errors.New("exit 1")); err.Error() != "az group list: ERROR: boom more" { + t.Errorf("generic: %v", err) + } +} + +func TestValuesCacheRoundTripAndTTL(t *testing.T) { + dir := t.TempDir() + now := time.Date(2026, 1, 1, 12, 0, 0, 0, time.UTC) + saveCachedValues(dir, "group list", []string{"a", "b"}, now) + if got, ok := loadCachedValues(dir, "group list", now.Add(time.Minute)); !ok || len(got) != 2 { + t.Errorf("fresh entry: %v, %v", got, ok) + } + if _, ok := loadCachedValues(dir, "group list", now.Add(valuesCacheTTL+time.Second)); ok { + t.Errorf("entry past TTL was served") + } + if _, ok := loadCachedValues(dir, "group list --subscription x", now); ok { + t.Errorf("entry served for a different command") + } + if _, ok := loadCachedValues("", "group list", now); ok { + t.Errorf("disabled cache served an entry") + } +} + +// `az account set` / `az login` rewrite azureProfile.json; values cached +// for the previous subscription must not be served afterwards. +func TestValuesCacheInvalidatedByProfileChange(t *testing.T) { + cfg := t.TempDir() + t.Setenv("AZURE_CONFIG_DIR", cfg) + profile := cfg + "/azureProfile.json" + if err := os.WriteFile(profile, []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + dir := t.TempDir() + now := time.Now() + saveCachedValues(dir, "group list", []string{"a"}, now) + if _, ok := loadCachedValues(dir, "group list", now); !ok { + t.Fatalf("fresh entry not served") + } + later := time.Now().Add(time.Hour) + if err := os.Chtimes(profile, later, later); err != nil { + t.Fatal(err) + } + if _, ok := loadCachedValues(dir, "group list", now); ok { + t.Errorf("entry served after the Azure profile changed") + } +} + +// fetchField sorts resource lists and serves the second request from the +// on-disk cache without starting az again. +func TestFetchFieldSortsAndCaches(t *testing.T) { + calls := stubAz(t, func([]string) ([]byte, []byte, error) { + return []byte(`[{"name":"rg-b"},{"name":"rg-a"}]`), nil, nil + }) + spec := fetchSpec{command: "group list", sorted: true, cacheDir: t.TempDir()} + msg := fetchField(3, spec)().(FieldFetchedMsg) + if msg.Err != nil || strings.Join(msg.Choices, ",") != "rg-a,rg-b" || msg.FieldIdx != 3 { + t.Fatalf("first fetch: %+v", msg) + } + msg = fetchField(3, spec)().(FieldFetchedMsg) + if strings.Join(msg.Choices, ",") != "rg-a,rg-b" { + t.Fatalf("cached fetch: %+v", msg) + } + if len(*calls) != 1 || (*calls)[0] != "group list --output json" { + t.Errorf("az calls = %v, want exactly one", *calls) + } +} + +// Opening a form with --resource-group starts `az group list` right away; +// by the time the user presses Enter on the field, the groups are offered. +func TestResourceGroupsPrefetchedAndPickable(t *testing.T) { + calls := stubAz(t, func([]string) ([]byte, []byte, error) { + return []byte(`[{"name":"prod-rg"},{"name":"dev-rg"}]`), nil, nil + }) + // vm create: --name is a new VM, so only the groups are listed. + f := NewForm("vm create", "/tmp/out.txt", t.TempDir(), "test", nil) + m, cmd := f.Update(MetadataLoadedMsg{Params: []metadata.Parameter{ + {Name: "--name", Required: true, TakesValue: true, ValueKind: metadata.ValueKindString}, + {Name: "--resource-group", Required: true, TakesValue: true, ValueKind: metadata.ValueKindString}, + }}) + f = m.(Form) + rg := f.FieldIndex("--resource-group") + if f.fields[rg].FetchState != FetchLoading { + t.Fatalf("--resource-group not prefetched: state=%v", f.fields[rg].FetchState) + } + for _, msg := range runCmd(cmd) { + if fm, ok := msg.(FieldFetchedMsg); ok { + m, _ = f.Update(fm) + f = m.(Form) + } + } + if got := strings.Join(f.fields[rg].FetchedChoices, ","); got != "dev-rg,prod-rg" { + t.Fatalf("choices = %q (calls %v)", got, *calls) + } + if n := len(*calls); n != 1 { + t.Errorf("az ran %d times, want 1 (--name of vm create has no source)", n) + } + + // Move to --resource-group, open the picker, pick the first group. + f.cursor = 1 + m, _ = f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + f = m.(Form) + if f.mode != FormModeEnum { + t.Fatalf("Enter should open the picker, mode=%v", f.mode) + } + m, _ = f.Update(tea.KeyMsg{Type: tea.KeyDown}) + f = m.(Form) + m, pick := f.Update(tea.KeyMsg{Type: tea.KeyEnter}) + f = m.(Form) + m, _ = f.Update(pick()) + f = m.(Form) + if got := f.fields[rg].Value; got != "dev-rg" { + t.Errorf("picked %q, want dev-rg", got) + } +} + +// Changing --subscription drops groups listed for the previous one. +func TestSubscriptionEditInvalidatesResourceGroups(t *testing.T) { + f := NewForm("vm show", "/tmp/out.txt", t.TempDir(), "test", nil) + m, _ := f.Update(MetadataLoadedMsg{Params: []metadata.Parameter{ + {Name: "--resource-group", TakesValue: true, ValueKind: metadata.ValueKindString}, + {Name: "--subscription", TakesValue: true, ValueKind: metadata.ValueKindString, Global: true}, + }}) + f = m.(Form) + rg := f.FieldIndex("--resource-group") + f.fields[rg].FetchState = FetchLoaded + f.fields[rg].FetchedChoices = []string{"old-rg"} + stubAz(t, func([]string) ([]byte, []byte, error) { return []byte(`[]`), nil, nil }) + if cmd := f.invalidateDependentFetches("--subscription"); cmd == nil { + t.Errorf("no refetch scheduled") + } + if f.fields[rg].FetchState != FetchLoading || f.fields[rg].FetchedChoices != nil { + t.Errorf("groups not refetched after --subscription change: state=%v", f.fields[rg].FetchState) + } +} + +// Grid cells have no room for "(N options)": the cell shows ▼ / ! and the +// footer names the count or the error for the focused field. +func TestGridShowsFetchState(t *testing.T) { + f := NewForm("vm show", "/tmp/out.txt", t.TempDir(), "test", nil) + m, _ := f.Update(tea.WindowSizeMsg{Width: 160, Height: 40}) + m, _ = m.(Form).Update(MetadataLoadedMsg{Params: []metadata.Parameter{ + {Name: "--resource-group", TakesValue: true, ValueKind: metadata.ValueKindString}, + {Name: "--output", TakesValue: true, ValueKind: metadata.ValueKindString, Global: true}, + }}) + f = m.(Form) + if _, _, cols := f.gridLayout(); cols < 2 { + t.Fatalf("expected grid layout, got %d column(s)", cols) + } + rg := f.FieldIndex("--resource-group") + f.cursor = 0 + f.fields[rg].FetchState = FetchLoaded + f.fields[rg].FetchedChoices = []string{"a", "b", "c"} + if note := stripANSI(f.gridFetchNote()); note != "--resource-group: 3 values — Enter to pick" { + t.Errorf("loaded note = %q", note) + } + if cell := stripANSI(f.renderGridCell(rg, true, 16)); !strings.Contains(cell, "▼") { + t.Errorf("loaded cell lacks ▼: %q", cell) + } + f.fields[rg].FetchState = FetchError + f.fields[rg].FetchError = "not signed in (run az login) — type a value" + if note := stripANSI(f.gridFetchNote()); !strings.Contains(note, "not signed in") { + t.Errorf("error note = %q", note) + } + if cell := stripANSI(f.renderGridCell(rg, true, 16)); !strings.Contains(cell, "!") { + t.Errorf("error cell lacks !: %q", cell) + } +} diff --git a/internal/ui/model.go b/internal/ui/model.go index 3d06793..bf90646 100644 --- a/internal/ui/model.go +++ b/internal/ui/model.go @@ -399,8 +399,9 @@ func (m Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } f := &m.fields[msg.FieldIdx] - if f.FetchState != FetchLoading { - // Stale completion (e.g. after Esc cancel) — ignore. + if f.FetchState != FetchLoading || msg.Gen != f.FetchGen { + // Stale completion (after Esc cancel, or started before the + // context it depends on changed) — ignore. return m, nil } if msg.Err != nil { @@ -477,17 +478,26 @@ func (m Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case EnumSelectedMsg: if m.mode == FormModeEnum && msg.Value == manualEntryChoice { // Fetched choices are suggestions, not a closed set: the - // first popup row switches to free-text input instead. - return m, m.openEditor(m.enumIdx) + // first popup row switches to free-text input instead. A + // search that found nothing is usually the value the user + // wants to type (a resource that does not exist yet), so it + // seeds the editor. + cmd := m.openEditor(m.enumIdx) + if msg.Query != "" { + m.textInput.SetValue(msg.Query) + m.textInput.CursorEnd() + } + return m, cmd } if m.mode == FormModeEnum { m.fields[m.enumIdx].Value = msg.Value m.fields[m.enumIdx].VarValue = "" m.fields[m.enumIdx].Mode = FieldModeLiteral m.fields[m.enumIdx].Enabled = true - m.invalidateDependentFetches(m.fields[m.enumIdx].Param.Name) + refetch := m.invalidateDependentFetches(m.fields[m.enumIdx].Param.Name) m.recomputeFindings(nil) m.mode = FormModeList + return m, refetch } return m, nil @@ -550,14 +560,46 @@ func (m Form) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, nil } -// sessionVarNames returns the union of vars.SessionVars names and vars.Vars -// names available in the current shell. +// valuesSource returns the command that lists f's values: az's own +// `Values from:` hint, else an implicit source for params that name an +// existing resource (implicitSource). implicit reports the latter; those +// lists are sorted and prefetched. +func (m *Form) valuesSource(f *Field) (source string, implicit bool) { + if vf := f.Param.ValuesFrom; vf != nil && *vf != "" { + return *vf, false + } + if s := implicitSource(m.command, f.Param); s != "" { + return s, true + } + return "", false +} + // maybeFetchField promotes a field from Idle to Loading and schedules the // three tea.Tick thresholds + the subprocess fetch. Returns nil when the -// field is not eligible (already loading/loaded, no ValuesFrom, metadata +// field is not eligible (already loading/loaded, no values source, metadata // already supplied choices). Caller chains the returned cmd with whatever // they were already returning. func (m *Form) maybeFetchField(idx int) tea.Cmd { + return m.startFetch(idx, true) +} + +// prefetchImplicit starts fetches for every field with an implicit source +// (resource groups, …) as soon as the form opens, so the list is usually +// ready by the time the user reaches the field. No slow/cancel footer +// hints: they would describe a field the user is not looking at. +func (m *Form) prefetchImplicit() tea.Cmd { + var cmds []tea.Cmd + for i := range m.fields { + if _, implicit := m.valuesSource(&m.fields[i]); implicit { + if cmd := m.startFetch(i, false); cmd != nil { + cmds = append(cmds, cmd) + } + } + } + return tea.Batch(cmds...) +} + +func (m *Form) startFetch(idx int, withHints bool) tea.Cmd { if idx < 0 || idx >= len(m.fields) { return nil } @@ -565,14 +607,14 @@ func (m *Form) maybeFetchField(idx int) tea.Cmd { if f.FetchState != FetchIdle { return nil } - vf := f.Param.ValuesFrom - if vf == nil || *vf == "" { + source, implicit := m.valuesSource(f) + if source == "" { return nil } if len(f.Param.Choices) > 0 { return nil } - command, ok := fetchCommand(*vf, m.contextValue) + command, ok := fetchCommand(source, m.contextValue) if !ok { // A required context param (e.g. --location for vm list-sizes) is // still empty. Stay idle; focusing the field again retries. @@ -583,18 +625,27 @@ func (m *Form) maybeFetchField(idx int) tea.Cmd { f.FetchStartedAt = time.Now() f.FetchedChoices = nil f.FetchError = "" - return tea.Batch( + spec := fetchSpec{command: command, sorted: implicit, gen: f.FetchGen} + if m.cache != nil { + spec.cacheDir = m.cache.Dir + } + cmds := []tea.Cmd{ tea.Tick(fetchSpinnerDelay, func(time.Time) tea.Msg { return FieldSpinnerShowMsg{FieldIdx: idx} }), - tea.Tick(fetchSlowThreshold, func(time.Time) tea.Msg { - return FieldFetchSlowMsg{FieldIdx: idx} - }), - tea.Tick(fetchCancelOffer, func(time.Time) tea.Msg { - return FieldFetchOfferCancelMsg{FieldIdx: idx} - }), - fetchField(idx, command), - ) + fetchField(idx, spec), + } + if withHints { + cmds = append(cmds, + tea.Tick(fetchSlowThreshold, func(time.Time) tea.Msg { + return FieldFetchSlowMsg{FieldIdx: idx} + }), + tea.Tick(fetchCancelOffer, func(time.Time) tea.Msg { + return FieldFetchOfferCancelMsg{FieldIdx: idx} + }), + ) + } + return tea.Batch(cmds...) } // contextValue returns the value an enabled param will pass to az — the @@ -613,23 +664,38 @@ func (m *Form) contextValue(param string) string { return "" } -// invalidateDependentFetches drops fetched choices that were computed from -// param's previous value (vm sizes for another --location, …) so the next -// focus fetches them again. -func (m *Form) invalidateDependentFetches(param string) { +// invalidateDependentFetches drops choices computed from param's previous +// value (vm sizes for another --location, VMs of another group, …). A +// fetch still in flight is superseded: its FetchGen is bumped so the late +// result is ignored. Implicit sources (existing resources) are refetched +// at once, like the prefetch on open, so the right list is ready when the +// user gets to the field; others refetch on their next focus. +func (m *Form) invalidateDependentFetches(param string) tea.Cmd { + var cmds []tea.Cmd for i := range m.fields { f := &m.fields[i] - if f.Param.ValuesFrom == nil || f.FetchState == FetchLoading { + source, implicit := m.valuesSource(f) + if source == "" { continue } - for _, p := range fetchContextParams(*f.Param.ValuesFrom) { - if p == param { - f.FetchState = FetchIdle - f.FetchedChoices = nil - f.FetchError = "" + for _, p := range fetchContextParams(source) { + if p != param { + continue + } + if f.FetchState == FetchLoading { + f.FetchGen++ + f.FetchSpinnerShow = false } + f.FetchState = FetchIdle + f.FetchedChoices = nil + f.FetchError = "" + if implicit { + cmds = append(cmds, m.startFetch(i, false)) + } + break } } + return tea.Batch(cmds...) } // anyFieldLoading reports whether any field is currently mid-fetch; used to @@ -643,6 +709,8 @@ func anyFieldLoading(fs []Field) bool { return false } +// sessionVarNames returns the union of vars.SessionVars names and vars.Vars +// names available in the current shell. func (m *Form) sessionVarNames() map[string]bool { set := map[string]bool{} for _, n := range m.src.SessionVars { diff --git a/internal/ui/prefill.go b/internal/ui/prefill.go index b24064b..ad37cf0 100644 --- a/internal/ui/prefill.go +++ b/internal/ui/prefill.go @@ -74,6 +74,7 @@ func (m Form) handleMetadataLoaded(msg MetadataLoadedMsg) (tea.Model, tea.Cmd) { if msg.Stale && msg.refresh != nil { cmds = append(cmds, refreshMetadata(msg.refresh)) } + cmds = append(cmds, m.prefetchImplicit()) // Kick off a lazy fetch for the initially focused field (spec §6.1). if idx := m.fieldAt(m.cursor); idx >= 0 { if cmd := m.maybeFetchField(idx); cmd != nil { diff --git a/internal/ui/view.go b/internal/ui/view.go index 77636ae..eef2a60 100644 --- a/internal/ui/view.go +++ b/internal/ui/view.go @@ -125,7 +125,7 @@ func (m Form) View() string { var popupHeight int switch m.mode { case FormModeEnum: - popupHeight = enumPopupHeight(m.enumPop.choices) + popupHeight = enumPopupHeight(m.enumPop.choices, m.enumPop.Header()) case FormModeVarPick: popupHeight = len(m.buildVarPickerBox()) } @@ -173,7 +173,7 @@ func (m Form) View() string { // Same bordered, right-aligned popup as grid mode. Lines // are padded with leading spaces so the popup box hugs the // terminal's right edge; content inside stays left-aligned. - popupLines := buildPopupLines(m.enumPop.choices, m.enumPop.cursor) + popupLines := buildPopupLines(m.enumPop.choices, m.enumPop.cursor, m.enumPop.Header()) popupWidth := 0 for _, ln := range popupLines { if w := runewidth.StringWidth(stripANSI(ln)); w > popupWidth { @@ -283,6 +283,8 @@ func (m Form) View() string { writeLine(&sb, errStyle.Render(m.errorMsg)) case m.hintMsg != "": writeLine(&sb, hintStyle.Render(m.hintMsg)) + case m.gridFetchNote() != "": + writeLine(&sb, m.gridFetchNote()) case focusedFullValue != "": // Grid mode shows a preview of the focused field's full value here so // truncated cells (`…`) don't hide information. Prefix with the field @@ -438,7 +440,7 @@ func (m Form) renderHelp() string { {"space", "toggle optional field on/off (required fields show a hint)"}, {"ctrl+g", "insert variable reference ($NAME) at cursor (edit mode)"}, {"esc", "close popup / cancel edit"}, - {"/", "filter visible parameters"}, + {"/", "filter visible parameters; in a popup list, search it"}, {"g", "set a shell variable (writes export to calling shell)"}, }, }, @@ -689,6 +691,31 @@ func (m *Form) renderFieldSelected(idx int, selected bool, nameWidth int) string return row } +// gridFetchNote is the footer line describing the focused field's value +// fetch in grid mode, where the cell itself only has room for a marker. +// Single-column rows show the same information inline, so it is empty +// there. +func (m *Form) gridFetchNote() string { + if m.mode != FormModeList { + return "" + } + idx := m.fieldAt(m.cursor) + if idx < 0 { + return "" + } + if _, _, cols := m.gridLayout(); cols < 2 { + return "" + } + f := &m.fields[idx] + switch { + case f.FetchState == FetchError && f.FetchError != "": + return errStyle.Render(f.Param.Name + ": " + f.FetchError) + case f.FetchState == FetchLoaded && len(f.FetchedChoices) > 0: + return hintStyle.Render(fmt.Sprintf("%s: %d values — Enter to pick", f.Param.Name, len(f.FetchedChoices))) + } + return "" +} + // fieldsFocused reports whether keyboard focus is on the field grid // rather than the Done/Cancel buttons. Used to suppress the cursor-row // highlight while Tab focus is on a button — the cursor position stays @@ -876,7 +903,7 @@ func (m *Form) renderGrid(roCols [][]int, globalsCol []int) (body string, cursor // the separator / preview region. When there's no room below, the // overlay anchors above the focused cell instead. func (m *Form) spliceEnumOverlay(lines []string, focusedRow, focusedCol int, cellWidths []int) []string { - return m.spliceOverlay(lines, focusedRow, focusedCol, cellWidths, m.enumPop.choices, m.enumPop.cursor) + return m.spliceOverlay(lines, focusedRow, focusedCol, cellWidths, m.enumPop.choices, m.enumPop.cursor, m.enumPop.Header()) } // spliceVarOverlay is the variable-picker's twin of spliceEnumOverlay. @@ -1101,16 +1128,10 @@ func buildTopBorder(innerWidth, cols, visibleCols, offset int) string { } // enumPopupHeight returns the number of terminal rows the enum popup -// will occupy for the given choices (top + visible items + bottom). -func enumPopupHeight(choices []string) int { - n := len(choices) - if n == 0 { - return 0 - } - if n > maxPopupItems { - n = maxPopupItems - } - return n + 2 +// will occupy: borders, the header row when there is one, and the visible +// items (a "no matches" row stands in for an empty search result). +func enumPopupHeight(choices []string, header string) int { + return len(buildPopupLines(choices, 0, header)) } // maxPopupItems caps the visible choice rows in an enum popup. Longer @@ -1125,21 +1146,23 @@ const maxPopupItems = 7 // than maxPopupItems choices are present, the visible slice slides to // keep the cursor in view and clipped sides are marked in the border. // Returns an empty slice when choices is empty (caller skips drawing). -func buildPopupLines(choices []string, cursor int) []string { +func buildPopupLines(choices []string, cursor int, header string) []string { n := len(choices) - if n == 0 { + if n == 0 && header == "" { return nil } - // Width = longest choice + cursor + padding, floored at gridValueBudget - // so single-character choices still get a comfortable popup. Matches - // the original spliceOverlay's min-width floor. - popupWidth := gridValueBudget - for _, c := range choices { - if w := runewidth.StringWidth(c) + 4; w > popupWidth { - popupWidth = w + popupWidth := popupWidthFor(choices, header) + interior := popupWidth - 2 + if n == 0 { + // A search that matches nothing still shows its query, so the + // user can see why the list is empty and edit it. + return []string{ + borderLine("┌", "┐", interior, false, ""), + popupRow(header, interior, hintStyle), + popupRow("(no matches)", interior, hintStyle), + borderLine("└", "┘", interior, false, ""), } } - interior := popupWidth - 2 visible := n if visible > maxPopupItems { @@ -1161,6 +1184,9 @@ func buildPopupLines(choices []string, cursor int) []string { var out []string out = append(out, borderLine("┌", "┐", interior, clipTop, "↑")) + if header != "" { + out = append(out, popupRow(header, interior, hintStyle)) + } for i := start; i < end; i++ { choice := choices[i] prefix := " " @@ -1196,6 +1222,30 @@ func buildPopupLines(choices []string, cursor int) []string { // borderLine builds a horizontal border ("┌────┐" style). When clipped // is true, the middle of the border shows `glyph` to signal off-screen // choices in that direction. +// popupWidthFor is the popup's outer width: longest row + cursor + +// padding, floored at gridValueBudget so single-character choices still +// get a comfortable popup. +func popupWidthFor(choices []string, header string) int { + w := gridValueBudget + for _, c := range append([]string{header}, choices...) { + if cw := runewidth.StringWidth(c) + 4; cw > w { + w = cw + } + } + return w +} + +// popupRow renders one non-choice row (search header, "no matches") +// inside the popup borders, indented like the choices. +func popupRow(text string, interior int, style lipgloss.Style) string { + maxText := interior - 2 + if runewidth.StringWidth(text) > maxText { + text = runewidth.Truncate(text, maxText-1, "…") + } + pad := strings.Repeat(" ", max(interior-2-runewidth.StringWidth(text), 0)) + return "│ " + style.Render(text) + pad + "│" +} + func borderLine(left, right string, interior int, clipped bool, glyph string) string { if !clipped || interior < 3 { var b strings.Builder @@ -1214,7 +1264,7 @@ func borderLine(left, right string, interior int, clipped bool, glyph string) st return b.String() } -func (m *Form) spliceOverlay(lines []string, focusedRow, focusedCol int, cellWidths []int, choices []string, cursor int) []string { +func (m *Form) spliceOverlay(lines []string, focusedRow, focusedCol int, cellWidths []int, choices []string, cursor int, header string) []string { if len(lines) == 0 || focusedRow < 0 || focusedCol < 0 || focusedCol >= len(cellWidths) { return lines } @@ -1225,19 +1275,14 @@ func (m *Form) spliceOverlay(lines []string, focusedRow, focusedCol int, cellWid return lines } - if len(choices) == 0 { + if len(choices) == 0 && header == "" { return lines } // Cap width at the cell's left-edge-to-terminal-right span so the // popup never wraps. Floor at gridValueBudget so single-character // choices still render legibly. - popupWidth := gridValueBudget - for _, c := range choices { - if w := runewidth.StringWidth(c) + 4; w > popupWidth { - popupWidth = w - } - } + popupWidth := popupWidthFor(choices, header) // Right-align the popup to the focused cell's right edge (the // end of the value column) so the box hugs the right side of the // param column instead of the terminal. Content inside stays @@ -1256,7 +1301,7 @@ func (m *Form) spliceOverlay(lines []string, focusedRow, focusedCol int, cellWid } } - popupLines := buildPopupLines(choices, cursor) + popupLines := buildPopupLines(choices, cursor, header) if len(popupLines) == 0 { return lines } @@ -1354,11 +1399,19 @@ func (m *Form) renderGridCell(idx int, selected bool, nameWidth int) string { // the value budget so the cell stays on one line. valDisplay = ansi.Truncate(m.textInput.View(), valueBudget, "") case f.Value == "": - placeholder := "—" - if f.Param.HasSelectChoices() { - placeholder = "▼" + // Cells have no room for single-column's "(N options)" suffix, so + // the fetch state replaces the empty-value placeholder; the footer + // carries the details for the focused cell (gridFetchNote). + switch { + case f.FetchState == FetchLoading && f.FetchSpinnerShow: + valDisplay = m.fieldSpinner.View() + case f.FetchState == FetchError: + valDisplay = errStyle.Render("!") + case f.Param.HasSelectChoices() || (f.FetchState == FetchLoaded && len(f.FetchedChoices) > 0): + valDisplay = hintStyle.Render("▼") + default: + valDisplay = hintStyle.Render("—") } - valDisplay = hintStyle.Render(placeholder) case f.Mode == FieldModeVar: status := StatusOf(f.Value, m.sessionVars) var v string