Files
Akihiro Suda 02eafe9cfe limactl-menu: add a form for creating new instances
Press `c` or F7 in the instance list (or "Create Instance" in the
empty state) to open a form for the instance name, template, CPUs,
memory, disk, VM type, and whether to start the instance after
creation. Empty fields fall back to the template defaults.

The other `limactl create` flags (mounts, networks, port forwards,
`--set`, `--param`, etc.) are available in the "Details" section, which
is hidden by default. Its fields are generated from the flag
definitions of `limactl create`, and the description of the focused
field is shown below the form.

To minimize free-text input, the values are chosen from dropdowns
whenever the choices are known: the shell completion values of the
flags, the drivers, and the image variants of the selected template.
"Other..." in a dropdown allows entering a value that is not in the
list.

"Create" runs `limactl start` (or `limactl create`) with the
corresponding flags. "Edit" opens an editor for the template YAML with
the form values applied, and then creates the instance from the edited
YAML. In both cases, the menu is suspended while limactl is running, and
the new instance is selected when returning to the menu.

The form can be navigated with the arrow keys as well as with Tab:
Up/Down move between the fields and the buttons (except in an open
dropdown list, and except between the lines of a text area), and
Left/Right move between the buttons.

The single-key shortcuts of the instance list are now only handled
while the list has the focus, so that they do not interfere with typing
into the form.

Fixes #5595

Assisted-by: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Signed-off-by: Akihiro Suda <akihiro.suda.cz@hco.ntt.co.jp>
2026-10-22 00:43:51 +09:00

256 lines
8.7 KiB
Go

// SPDX-FileCopyrightText: Copyright The Lima Authors
// SPDX-License-Identifier: Apache-2.0
package main
import (
"fmt"
"strings"
"testing"
"github.com/gdamore/tcell/v2"
"github.com/rivo/tview"
"gotest.tools/v3/assert"
)
func TestCreateOptionsArgs(t *testing.T) {
opts := &createOptions{Template: "default"}
assert.DeepEqual(t, opts.args(), []string{"create", "--tty=false", "template:default"})
opts = &createOptions{
Name: "foo", Template: "docker", Start: true,
Flags: []string{"--cpus=4", "--memory=8", "--mount-type=9p", "--plain"},
}
assert.DeepEqual(t, opts.args(), []string{
"start", "--tty=false", "--name=foo", "--cpus=4", "--memory=8", "--mount-type=9p", "--plain", "template:docker",
})
}
func TestCreateOptionsValidate(t *testing.T) {
existing := []string{"default", "docker"}
assert.NilError(t, (&createOptions{Template: "fedora"}).validate(existing))
assert.NilError(t, (&createOptions{
Name: "foo", Template: "docker",
Flags: []string{"--cpus=2", "--memory=1.5", "--disk=10", "--ssh-port=2222"},
}).validate(existing))
assert.ErrorContains(t, (&createOptions{}).validate(existing), "template")
assert.ErrorContains(t, (&createOptions{Name: "default", Template: "default"}).validate(existing), "already exists")
assert.ErrorContains(t, (&createOptions{Template: "docker"}).validate(existing), "already exists")
assert.ErrorContains(t, (&createOptions{Name: "foo/bar", Template: "default"}).validate(existing), "invalid name")
for flag, msg := range map[string]string{
"--ssh-port=abc": "ssh-port",
"--cpus=1.5": "cpus",
"--cpus=0": "CPUs",
"--memory=0": "memory",
"--disk=-1": "disk",
} {
assert.ErrorContains(t, (&createOptions{Template: "fedora", Flags: []string{flag}}).validate(existing), msg, flag)
}
}
func TestDefaultInstanceName(t *testing.T) {
assert.Equal(t, defaultInstanceName(nil), "default")
assert.Equal(t, defaultInstanceName([]string{"docker"}), "default")
assert.Equal(t, defaultInstanceName([]string{"default"}), "")
}
func TestCreateOptionsStdinArgs(t *testing.T) {
opts := &createOptions{Template: "docker", Flags: []string{"--cpus=4"}}
assert.DeepEqual(t, opts.stdinArgs("foo"), []string{"create", "--tty=false", "--name=foo", "-"})
opts.Start = true
assert.DeepEqual(t, opts.stdinArgs("foo"), []string{"start", "--tty=false", "--name=foo", "-"})
}
func TestCreateOptionsInstName(t *testing.T) {
name, err := (&createOptions{Name: "foo", Template: "docker"}).instName()
assert.NilError(t, err)
assert.Equal(t, name, "foo")
name, err = (&createOptions{Template: "docker"}).instName()
assert.NilError(t, err)
assert.Equal(t, name, "docker")
}
func TestCreateOptionsApplyToYAML(t *testing.T) {
y := []byte("images: []\ncpus: null\nparam:\n FOO: foo\n")
out, err := (&createOptions{}).applyToYAML(t.Context(), y)
assert.NilError(t, err)
assert.Equal(t, string(out), string(y))
opts := &createOptions{Flags: []string{
"--cpus=4", "--memory=8", "--disk=50", "--vm-type=qemu",
"--mount-type=9p", "--set=.ssh.forwardAgent = true", "--param=FOO=bar",
}}
out, err = opts.applyToYAML(t.Context(), y)
assert.NilError(t, err)
for _, s := range []string{
"cpus: 4", "memory: 8GiB", "disk: 50GiB", "vmType: qemu",
"mountType: 9p", "forwardAgent: true", "FOO: bar",
} {
assert.Assert(t, strings.Contains(string(out), s), string(out))
}
opts = &createOptions{Flags: []string{"--param=BAR=bar"}}
_, err = opts.applyToYAML(t.Context(), y)
assert.ErrorContains(t, err, "BAR")
}
func TestChoiceField(t *testing.T) {
c := newChoiceField("mount-type", []string{"reverse-sshfs", "9p"})
assert.Equal(t, c.GetOptionCount(), 4)
assert.Equal(t, c.value(), "")
c.setValue("9p")
assert.Equal(t, c.value(), "9p")
// Choosing "Other..." asks promptFunc for a value, which is added to the choices.
c.promptFunc = func(c *choiceField) { c.setValue("virtiofs") }
c.SetCurrentOption(c.GetOptionCount() - 1)
assert.Equal(t, c.value(), "virtiofs")
assert.DeepEqual(t, c.options(), []string{defaultChoice, "reverse-sshfs", "9p", "virtiofs", otherChoice})
// Canceling "Other..." restores the previous value.
c.promptFunc = func(c *choiceField) { c.restore() }
c.SetCurrentOption(c.GetOptionCount() - 1)
assert.Equal(t, c.value(), "virtiofs")
// Replacing the choices keeps the current value.
c.setChoices([]string{"9p"})
assert.Equal(t, c.value(), "virtiofs")
assert.DeepEqual(t, c.options(), []string{defaultChoice, "9p", "virtiofs", otherChoice})
c.setValue("")
assert.Equal(t, c.value(), "")
}
func TestFormArrowKey(t *testing.T) {
textArea := tview.NewTextArea().SetLabel("set").SetWrap(false)
form := tview.NewForm().
AddInputField("Name", "", 10, nil, nil).
AddFormItem(newChoiceField("Template", []string{"default", "docker"})).
AddFormItem(textArea).
AddButton("Create", nil)
key := func(k tcell.Key) tcell.Key {
return formArrowKey(form, tcell.NewEventKey(k, 0, tcell.ModNone)).Key()
}
// Input field: Up/Down move between the fields, Left/Right move the cursor.
form.SetFocus(0)
assert.Equal(t, key(tcell.KeyDown), tcell.KeyTab)
assert.Equal(t, key(tcell.KeyUp), tcell.KeyBacktab)
assert.Equal(t, key(tcell.KeyLeft), tcell.KeyLeft)
assert.Equal(t, key(tcell.KeyRight), tcell.KeyRight)
// Closed dropdown: Up/Down move between the fields.
form.SetFocus(1)
assert.Equal(t, key(tcell.KeyDown), tcell.KeyTab)
assert.Equal(t, key(tcell.KeyUp), tcell.KeyBacktab)
// Text area: Up/Down move between the lines, and leave from the first or the last line.
form.SetFocus(2)
assert.Equal(t, key(tcell.KeyDown), tcell.KeyTab)
assert.Equal(t, key(tcell.KeyUp), tcell.KeyBacktab)
textArea.SetText("a\nb", true) // The cursor is moved to the end, i.e., the last line.
assert.Equal(t, key(tcell.KeyDown), tcell.KeyTab)
assert.Equal(t, key(tcell.KeyUp), tcell.KeyUp)
assert.Equal(t, key(tcell.KeyLeft), tcell.KeyLeft)
textArea.Select(0, 0)
assert.Equal(t, key(tcell.KeyDown), tcell.KeyDown)
assert.Equal(t, key(tcell.KeyUp), tcell.KeyBacktab)
// Button: all the arrow keys move between the fields and the buttons.
form.SetFocus(3)
assert.Equal(t, key(tcell.KeyDown), tcell.KeyTab)
assert.Equal(t, key(tcell.KeyUp), tcell.KeyBacktab)
assert.Equal(t, key(tcell.KeyRight), tcell.KeyTab)
assert.Equal(t, key(tcell.KeyLeft), tcell.KeyBacktab)
}
func fieldKinds(fields []flagField) map[string]string {
kinds := make(map[string]string)
for _, f := range fields {
kinds[f.flag.Name] = fmt.Sprintf("%T", f.item)
}
return kinds
}
func TestNewBasicFields(t *testing.T) {
fields := newBasicFields(newCreateCommand(), []string{"qemu", "vz"})
assert.DeepEqual(t, fieldKinds(fields), map[string]string{
"cpus": "*main.choiceField", "memory": "*main.choiceField",
"disk": "*main.choiceField", "vm-type": "*main.choiceField",
})
for _, f := range fields {
c := f.item.(*choiceField)
assert.Assert(t, len(c.choices) > 0, f.flag.Name)
if f.flag.Name == "vm-type" {
assert.DeepEqual(t, c.choices, []string{"qemu", "vz"})
}
}
}
func TestNewDetailFields(t *testing.T) {
kinds := fieldKinds(newDetailFields(newCreateCommand()))
for _, b := range basicFlags {
_, ok := kinds[b.name]
assert.Assert(t, !ok, "basic flag %q must not be in the details", b.name)
}
for name, kind := range map[string]string{
"plain": "*tview.Checkbox",
"set": "*tview.TextArea",
"port-forward": "*tview.TextArea",
"mount": "*tview.InputField",
"mount-type": "*main.choiceField",
"arch": "*main.choiceField",
"containerd": "*main.choiceField",
"network": "*main.choiceField",
"shell": "*main.choiceField",
"audio-interface": "*main.choiceField",
"image-variant": "*main.choiceField",
"arch-variant": "*main.choiceField",
} {
assert.Equal(t, kinds[name], kind, name)
}
}
func TestFlagValues(t *testing.T) {
fields := newDetailFields(newCreateCommand())
for _, f := range fields {
switch f.flag.Name {
case "plain":
f.item.(*tview.Checkbox).SetChecked(true)
case "set":
f.item.(*tview.TextArea).SetText(".cpus = 2\n\n .memory = \"2GiB\" \n", false)
case "mount":
f.item.(*tview.InputField).SetText(" /tmp/a,/tmp/b:w ")
case "mount-type":
f.item.(*choiceField).setValue("9p")
}
}
assert.DeepEqual(t, flagValues(fields), []string{
"--mount=/tmp/a,/tmp/b:w", "--mount-type=9p", "--plain", "--set=.cpus = 2", `--set=.memory = "2GiB"`,
})
}
func TestTemplateVariants(t *testing.T) {
y := []byte(`images:
- location: a
variant: server
- location: b
variant: minimal
archVariant: v3
- location: c
variant: server
archVariant: v2
`)
variants, archVariants := templateVariants(y)
assert.DeepEqual(t, variants, []string{"minimal", "server"})
assert.DeepEqual(t, archVariants, []string{"v2", "v3"})
variants, archVariants = templateVariants(nil)
assert.Assert(t, variants == nil && archVariants == nil)
}