Skip to content
Merged
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
35 changes: 34 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down
117 changes: 101 additions & 16 deletions internal/ui/enum.go
Original file line number Diff line number Diff line change
Expand Up @@ -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{}
Expand All @@ -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).
Expand All @@ -34,38 +48,109 @@ 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) {
km, ok := msg.(tea.KeyMsg)
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
Expand Down
133 changes: 133 additions & 0 deletions internal/ui/enum_search_test.go
Original file line number Diff line number Diff line change
@@ -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())
}
}
Loading
Loading