Make TUI startup render immediately

The TUI felt hung because banger tui blocked on ensureDaemon before Bubble Tea started, then treated the initial vm and image fetch as one combined loading gate.

Start the program first, move daemon bootstrap into staged TUI commands, render the full layout with inline loading placeholders, and split vm list and image list startup so VMs can appear before images finish loading. Also record per-stage timings so the slow step is visible in the status line instead of hidden behind a generic loading state.

Verified with go test ./internal/cli, go test ./..., and make build.
This commit is contained in:
Thales Maciel 2026-03-18 14:37:17 -03:00
parent 8ba920eda6
commit 3a92362829
No known key found for this signature in database
GPG key ID: 33112E6833C34679
2 changed files with 467 additions and 54 deletions

View file

@ -31,6 +31,17 @@ import (
const tuiRefreshInterval = 3 * time.Second const tuiRefreshInterval = 3 * time.Second
var (
tuiEnsureDaemonFunc = ensureDaemon
tuiProgramRunner = func(model tuiModel) error {
program := tea.NewProgram(model, tea.WithAltScreen())
return program.Start()
}
tuiIsTerminal = func(fd uintptr) bool {
return isatty.IsTerminal(fd)
}
)
type tuiMode int type tuiMode int
const ( const (
@ -52,11 +63,27 @@ const (
actionLogs actionKind = "logs" actionLogs actionKind = "logs"
) )
type dataLoadedMsg struct { type daemonReadyMsg struct {
vms []model.VMRecord generation int
images []model.Image layout paths.Layout
focusID string cfg model.DaemonConfig
err error duration time.Duration
err error
}
type vmListLoadedMsg struct {
generation int
vms []model.VMRecord
focusID string
duration time.Duration
err error
}
type imageListLoadedMsg struct {
generation int
images []model.Image
duration time.Duration
err error
} }
type statsLoadedMsg struct { type statsLoadedMsg struct {
@ -447,9 +474,18 @@ type tuiModel struct {
height int height int
ready bool ready bool
loading bool loadGeneration int
busy string loading bool
sudoValidated bool busy string
sudoValidated bool
daemonReady bool
daemonPending bool
vmListPending bool
imagePending bool
imagesLoaded bool
daemonLoadDur time.Duration
vmListDur time.Duration
imageListDur time.Duration
mode tuiMode mode tuiMode
form *vmForm form *vmForm
@ -507,19 +543,26 @@ func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
helpView := help.New() helpView := help.New()
return tuiModel{ model := tuiModel{
layout: layout, layout: layout,
cfg: cfg, cfg: cfg,
table: vmTable, width: 120,
detail: detail, height: 32,
help: helpView, ready: true,
spinner: spin, table: vmTable,
browseKeys: newBrowseKeyMap(), detail: detail,
formKeys: newFormKeyMap(), help: helpView,
confirmKeys: newConfirmKeyMap(), spinner: spin,
loading: true, browseKeys: newBrowseKeyMap(),
statusText: "Loading VMs...", formKeys: newFormKeyMap(),
confirmKeys: newConfirmKeyMap(),
loadGeneration: 1,
loading: true,
daemonPending: true,
statusText: "Starting daemon...",
} }
model.resize()
return model
} }
func newTUICommand() *cobra.Command { func newTUICommand() *cobra.Command {
@ -528,21 +571,16 @@ func newTUICommand() *cobra.Command {
Short: "Launch a terminal UI to manage VMs", Short: "Launch a terminal UI to manage VMs",
Args: noArgsUsage("usage: banger tui"), Args: noArgsUsage("usage: banger tui"),
RunE: func(cmd *cobra.Command, args []string) error { RunE: func(cmd *cobra.Command, args []string) error {
if !isatty.IsTerminal(os.Stdin.Fd()) || !isatty.IsTerminal(os.Stdout.Fd()) { if !tuiIsTerminal(os.Stdin.Fd()) || !tuiIsTerminal(os.Stdout.Fd()) {
return errors.New("tui requires an interactive terminal") return errors.New("tui requires an interactive terminal")
} }
layout, cfg, err := ensureDaemon(cmd.Context()) return tuiProgramRunner(newTUIModel(paths.Layout{}, model.DaemonConfig{}))
if err != nil {
return err
}
program := tea.NewProgram(newTUIModel(layout, cfg), tea.WithAltScreen())
return program.Start()
}, },
} }
} }
func (m tuiModel) Init() tea.Cmd { func (m tuiModel) Init() tea.Cmd {
return tea.Batch(m.spinner.Tick, refreshTickCmd(), fetchDataCmd(m.layout, "")) return tea.Batch(m.spinner.Tick, refreshTickCmd(), ensureDaemonCmd(m.loadGeneration))
} }
func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
@ -560,14 +598,34 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.width = msg.Width m.width = msg.Width
m.height = msg.Height m.height = msg.Height
m.resize() m.resize()
case dataLoadedMsg: case daemonReadyMsg:
m.loading = false if msg.generation != m.loadGeneration {
if msg.err != nil { break
m.setStatus(msg.err.Error(), true) }
m.daemonPending = false
if msg.err != nil {
m.syncLoadingState()
m.setStatus(fmt.Sprintf("starting daemon: %v", msg.err), true)
break
}
m.layout = msg.layout
m.cfg = msg.cfg
m.daemonReady = true
m.daemonLoadDur = msg.duration
m.beginListLoad("")
cmds = append(cmds, m.spinner.Tick, fetchVMListCmd(m.layout, "", m.loadGeneration), fetchImageListCmd(m.layout, m.loadGeneration))
case vmListLoadedMsg:
if msg.generation != m.loadGeneration {
break
}
m.vmListPending = false
if msg.err != nil {
m.syncLoadingState()
m.setStatus(fmt.Sprintf("loading vms: %v", msg.err), true)
break break
} }
m.images = msg.images
m.vms = msg.vms m.vms = msg.vms
m.vmListDur = msg.duration
targetID := m.selectedID targetID := m.selectedID
if msg.focusID != "" { if msg.focusID != "" {
targetID = msg.focusID targetID = msg.focusID
@ -575,7 +633,8 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.selectedID = resolveSelectedID(targetID, msg.vms) m.selectedID = resolveSelectedID(targetID, msg.vms)
m.lastRefresh = time.Now() m.lastRefresh = time.Now()
m.rebuildTable() m.rebuildTable()
m.setStatus(fmt.Sprintf("Loaded %d VM(s)", len(msg.vms)), false) m.syncLoadingState()
m.setLoadStatus(false)
if m.selectedID != "" { if m.selectedID != "" {
cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID)) cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID))
} else { } else {
@ -583,6 +642,21 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
m.statsID = "" m.statsID = ""
m.statsErr = "" m.statsErr = ""
} }
case imageListLoadedMsg:
if msg.generation != m.loadGeneration {
break
}
m.imagePending = false
if msg.err != nil {
m.syncLoadingState()
m.setStatus(fmt.Sprintf("loading images: %v", msg.err), true)
break
}
m.images = msg.images
m.imagesLoaded = true
m.imageListDur = msg.duration
m.syncLoadingState()
m.setLoadStatus(false)
case statsLoadedMsg: case statsLoadedMsg:
if msg.id != m.selectedID { if msg.id != m.selectedID {
break break
@ -613,8 +687,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
m.setStatus(msg.status, false) m.setStatus(msg.status, false)
if msg.refresh { if msg.refresh {
m.loading = true cmds = append(cmds, m.beginRefreshLoad(msg.focusID)...)
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, msg.focusID))
} }
case externalPreparedMsg: case externalPreparedMsg:
if msg.err != nil { if msg.err != nil {
@ -646,9 +719,8 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
} }
case refreshTickMsg: case refreshTickMsg:
cmds = append(cmds, refreshTickCmd()) cmds = append(cmds, refreshTickCmd())
if m.busy == "" && m.mode == tuiModeBrowse { if m.busy == "" && m.mode == tuiModeBrowse && m.daemonReady && !m.vmListPending && !m.imagePending {
m.loading = true cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...)
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID))
} }
case tea.KeyMsg: case tea.KeyMsg:
switch m.mode { switch m.mode {
@ -679,9 +751,16 @@ func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
case key.Matches(msg, m.browseKeys.help): case key.Matches(msg, m.browseKeys.help):
m.help.ShowAll = !m.help.ShowAll m.help.ShowAll = !m.help.ShowAll
case key.Matches(msg, m.browseKeys.refresh): case key.Matches(msg, m.browseKeys.refresh):
m.loading = true if !m.daemonReady {
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID)) cmds = append(cmds, m.beginBootstrapLoad()...)
} else {
cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...)
}
case key.Matches(msg, m.browseKeys.create): case key.Matches(msg, m.browseKeys.create):
if !m.imagesLoaded {
m.setStatus("Images are still loading", true)
return m, cmds
}
if len(m.images) == 0 { if len(m.images) == 0 {
m.setStatus("Create requires at least one image", true) m.setStatus("Create requires at least one image", true)
return m, cmds return m, cmds
@ -827,9 +906,6 @@ func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
} }
func (m tuiModel) View() string { func (m tuiModel) View() string {
if !m.ready {
return "Loading..."
}
header := m.renderHeader() header := m.renderHeader()
body := m.renderBody() body := m.renderBody()
status := m.renderStatus() status := m.renderStatus()
@ -876,16 +952,20 @@ func (m tuiModel) bodyHeight() int {
func (m tuiModel) renderHeader() string { func (m tuiModel) renderHeader() string {
status := "idle" status := "idle"
if m.loading { if m.busy != "" {
status = m.spinner.View() + " loading"
} else if m.busy != "" {
status = m.spinner.View() + " " + m.busy status = m.spinner.View() + " " + m.busy
} else if phase := m.loadingPhase(); phase != "" {
status = m.spinner.View() + " " + phase
} }
refreshed := "never" refreshed := "never"
if !m.lastRefresh.IsZero() { if !m.lastRefresh.IsZero() {
refreshed = relativeTime(m.lastRefresh) refreshed = relativeTime(m.lastRefresh)
} }
header := fmt.Sprintf("banger tui socket %s %s last refresh %s", filepath.Base(m.layout.SocketPath), status, refreshed) socketLabel := filepath.Base(m.layout.SocketPath)
if socketLabel == "." || socketLabel == "" {
socketLabel = "pending"
}
header := fmt.Sprintf("banger tui socket %s %s last refresh %s", socketLabel, status, refreshed)
return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header) return lipgloss.NewStyle().Bold(true).Width(m.width).Render(header)
} }
@ -909,7 +989,7 @@ func (m tuiModel) renderBody() string {
leftContent := m.table.View() leftContent := m.table.View()
if len(m.vms) == 0 { if len(m.vms) == 0 {
leftContent = "No VMs.\n\nPress c to create one." leftContent = m.vmListPlaceholder()
} }
leftPanel := lipgloss.NewStyle(). leftPanel := lipgloss.NewStyle().
Border(lipgloss.NormalBorder()). Border(lipgloss.NormalBorder()).
@ -979,7 +1059,7 @@ func (m *tuiModel) rebuildTable() {
func (m *tuiModel) refreshDetail() { func (m *tuiModel) refreshDetail() {
vm, ok := m.selectedVM() vm, ok := m.selectedVM()
if !ok { if !ok {
m.detail.SetContent("No VM selected.\n\nUse c to create a VM.") m.detail.SetContent(m.detailPlaceholder())
return return
} }
stats := vm.Stats stats := vm.Stats
@ -1031,6 +1111,158 @@ func (m *tuiModel) setStatus(text string, isErr bool) {
m.statusErr = isErr m.statusErr = isErr
} }
func (m *tuiModel) beginBootstrapLoad() []tea.Cmd {
m.loadGeneration++
m.daemonReady = false
m.daemonPending = true
m.vmListPending = false
m.imagePending = false
m.imagesLoaded = false
m.images = nil
m.daemonLoadDur = 0
m.vmListDur = 0
m.imageListDur = 0
m.syncLoadingState()
m.setLoadStatus(false)
return []tea.Cmd{m.spinner.Tick, ensureDaemonCmd(m.loadGeneration)}
}
func (m *tuiModel) beginListLoad(focusID string) {
m.vmListPending = true
m.imagePending = true
if len(m.images) == 0 {
m.imagesLoaded = false
}
m.vmListDur = 0
m.imageListDur = 0
m.syncLoadingState()
m.setLoadStatus(false)
if focusID == "" {
return
}
m.selectedID = focusID
}
func (m *tuiModel) beginRefreshLoad(focusID string) []tea.Cmd {
if !m.daemonReady {
return m.beginBootstrapLoad()
}
m.loadGeneration++
m.beginListLoad(focusID)
return []tea.Cmd{
m.spinner.Tick,
fetchVMListCmd(m.layout, focusID, m.loadGeneration),
fetchImageListCmd(m.layout, m.loadGeneration),
}
}
func (m *tuiModel) syncLoadingState() {
m.loading = m.daemonPending || m.vmListPending || m.imagePending
}
func (m tuiModel) loadingPhase() string {
switch {
case m.daemonPending:
return "starting daemon"
case m.vmListPending && m.imagePending:
return "loading vms and images"
case m.vmListPending:
return "loading vms"
case m.imagePending:
return "loading images"
default:
return ""
}
}
func (m *tuiModel) setLoadStatus(isErr bool) {
if phase := m.loadingPhase(); phase != "" {
durations := m.stageDurations()
switch phase {
case "loading images":
prefix := fmt.Sprintf("Loaded %d VM(s); loading images", len(m.vms))
if durations != "" {
m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr)
return
}
m.setStatus(prefix+"...", isErr)
return
case "loading vms":
prefix := fmt.Sprintf("Loaded %d image(s); loading vms", len(m.images))
if durations != "" {
m.setStatus(fmt.Sprintf("%s (%s)", prefix, durations), isErr)
return
}
m.setStatus(prefix+"...", isErr)
return
}
if durations != "" {
m.setStatus(fmt.Sprintf("%s (%s)", capitalizePhase(phase), durations), isErr)
return
}
m.setStatus(capitalizePhase(phase)+"...", isErr)
return
}
if m.daemonReady && m.vmListDur > 0 && m.imageListDur > 0 {
m.setStatus(fmt.Sprintf("Loaded %d VM(s) (%s)", len(m.vms), m.stageDurations()), isErr)
}
}
func (m tuiModel) stageDurations() string {
parts := make([]string, 0, 3)
if m.daemonLoadDur > 0 {
parts = append(parts, "daemon "+formatTUIDuration(m.daemonLoadDur))
}
if m.vmListDur > 0 {
parts = append(parts, "vm list "+formatTUIDuration(m.vmListDur))
}
if m.imageListDur > 0 {
parts = append(parts, "image list "+formatTUIDuration(m.imageListDur))
}
return strings.Join(parts, ", ")
}
func (m tuiModel) vmListPlaceholder() string {
switch {
case m.daemonPending:
return "Starting daemon...\n\nWaiting for bangerd to become ready."
case m.vmListPending:
return "Loading VMs..."
default:
return "No VMs.\n\nPress c to create one."
}
}
func (m tuiModel) detailPlaceholder() string {
switch {
case m.daemonPending:
return "Starting daemon...\n\nThe TUI will populate once bangerd is ready."
case m.vmListPending:
return "Loading VMs..."
case len(m.vms) == 0:
if m.imagePending {
return "No VM selected.\n\nImages are still loading."
}
return "No VM selected.\n\nUse c to create a VM."
default:
return "No VM selected."
}
}
func ensureDaemonCmd(generation int) tea.Cmd {
return func() tea.Msg {
start := time.Now()
layout, cfg, err := tuiEnsureDaemonFunc(context.Background())
return daemonReadyMsg{
generation: generation,
layout: layout,
cfg: cfg,
duration: time.Since(start),
err: err,
}
}
}
func (m tuiModel) selectedVM() (model.VMRecord, bool) { func (m tuiModel) selectedVM() (model.VMRecord, bool) {
for _, vm := range m.vms { for _, vm := range m.vms {
if vm.ID == m.selectedID { if vm.ID == m.selectedID {
@ -1193,17 +1425,34 @@ func prepareLogsCmd(layout paths.Layout, action actionRequest) tea.Cmd {
} }
} }
func fetchDataCmd(layout paths.Layout, focusID string) tea.Cmd { func fetchVMListCmd(layout paths.Layout, focusID string, generation int) tea.Cmd {
return func() tea.Msg { return func() tea.Msg {
start := time.Now()
vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{}) vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{})
if err != nil { if err != nil {
return dataLoadedMsg{err: err, focusID: focusID} return vmListLoadedMsg{generation: generation, err: err, focusID: focusID}
} }
return vmListLoadedMsg{
generation: generation,
vms: vms.VMs,
focusID: focusID,
duration: time.Since(start),
}
}
}
func fetchImageListCmd(layout paths.Layout, generation int) tea.Cmd {
return func() tea.Msg {
start := time.Now()
images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{}) images, err := rpc.Call[api.ImageListResult](context.Background(), layout.SocketPath, "image.list", api.Empty{})
if err != nil { if err != nil {
return dataLoadedMsg{vms: vms.VMs, err: err, focusID: focusID} return imageListLoadedMsg{generation: generation, err: err}
}
return imageListLoadedMsg{
generation: generation,
images: images.Images,
duration: time.Since(start),
} }
return dataLoadedMsg{vms: vms.VMs, images: images.Images, focusID: focusID}
} }
} }
@ -1237,6 +1486,24 @@ func resolveSelectedID(targetID string, vms []model.VMRecord) string {
return vms[0].ID return vms[0].ID
} }
func capitalizePhase(value string) string {
if value == "" {
return ""
}
return strings.ToUpper(value[:1]) + value[1:]
}
func formatTUIDuration(value time.Duration) string {
switch {
case value >= time.Second:
return value.Round(100 * time.Millisecond).String()
case value >= 100*time.Millisecond:
return value.Round(10 * time.Millisecond).String()
default:
return value.Round(time.Millisecond).String()
}
}
func imageNames(images []model.Image) []string { func imageNames(images []model.Image) []string {
names := make([]string, 0, len(images)) names := make([]string, 0, len(images))
for _, image := range images { for _, image := range images {

View file

@ -1,9 +1,15 @@
package cli package cli
import ( import (
"context"
"strings"
"testing" "testing"
"time"
"banger/internal/model" "banger/internal/model"
"banger/internal/paths"
tea "github.com/charmbracelet/bubbletea"
) )
func TestCreateVMFormSubmit(t *testing.T) { func TestCreateVMFormSubmit(t *testing.T) {
@ -87,3 +93,143 @@ func TestResolveSelectedID(t *testing.T) {
t.Fatalf("resolveSelectedID empty = %q, want empty", got) t.Fatalf("resolveSelectedID empty = %q, want empty", got)
} }
} }
func TestNewTUICommandStartsProgramWithoutEnsuringDaemon(t *testing.T) {
origEnsure := tuiEnsureDaemonFunc
origRunner := tuiProgramRunner
origTerminal := tuiIsTerminal
t.Cleanup(func() {
tuiEnsureDaemonFunc = origEnsure
tuiProgramRunner = origRunner
tuiIsTerminal = origTerminal
})
ensureCalled := false
tuiEnsureDaemonFunc = func(ctx context.Context) (paths.Layout, model.DaemonConfig, error) {
ensureCalled = true
return paths.Layout{}, model.DaemonConfig{}, nil
}
tuiProgramRunner = func(model tuiModel) error {
if ensureCalled {
t.Fatal("ensureDaemon should not run before the TUI starts")
}
if !model.daemonPending || !model.loading {
t.Fatalf("startup model = %+v, want pending daemon startup", model)
}
return nil
}
tuiIsTerminal = func(fd uintptr) bool { return true }
cmd := NewBangerCommand()
cmd.SetArgs([]string{"tui"})
if err := cmd.Execute(); err != nil {
t.Fatalf("Execute: %v", err)
}
if ensureCalled {
t.Fatal("ensureDaemon should not have been called")
}
}
func TestTUIViewRendersLayoutImmediately(t *testing.T) {
m := newTUIModel(paths.Layout{}, model.DaemonConfig{})
view := m.View()
if strings.Contains(view, "Loading...") {
t.Fatalf("view = %q, want full layout instead of one-line loading", view)
}
if !strings.Contains(view, "Starting daemon") {
t.Fatalf("view = %q, want startup placeholder", view)
}
}
func TestTUIVMLoadCanCompleteBeforeImages(t *testing.T) {
now := time.Date(2026, time.March, 18, 12, 0, 0, 0, time.UTC)
initial := newTUIModel(paths.Layout{}, model.DaemonConfig{})
updated, _ := initial.Update(daemonReadyMsg{
generation: initial.loadGeneration,
layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"},
cfg: model.DaemonConfig{DefaultImageName: "default"},
duration: 2400 * time.Millisecond,
})
m := updated.(tuiModel)
if !m.daemonReady || !m.vmListPending || !m.imagePending {
t.Fatalf("model after daemonReady = %+v, want pending vm/image loads", m)
}
vm := model.VMRecord{
ID: "vm-1",
Name: "devbox",
State: model.VMStateRunning,
CreatedAt: now,
UpdatedAt: now,
LastTouchedAt: now,
Spec: model.VMSpec{
VCPUCount: 2,
MemoryMiB: 1024,
WorkDiskSizeBytes: 16 * 1024 * 1024 * 1024,
},
Runtime: model.VMRuntime{
GuestIP: "172.16.0.2",
DNSName: "devbox.vm",
},
}
updated, _ = m.Update(vmListLoadedMsg{
generation: m.loadGeneration,
vms: []model.VMRecord{vm},
duration: 20 * time.Millisecond,
})
m = updated.(tuiModel)
if len(m.vms) != 1 || m.selectedID != vm.ID {
t.Fatalf("model after vmListLoaded = %+v, want selected vm", m)
}
if !m.imagePending {
t.Fatalf("image load should still be pending: %+v", m)
}
if strings.Contains(m.View(), "No VMs") {
t.Fatalf("view should render the loaded VM while images are pending: %q", m.View())
}
if !strings.Contains(m.View(), "devbox") {
t.Fatalf("view = %q, want loaded VM name", m.View())
}
}
func TestTUICreateBlockedWhileImagesLoad(t *testing.T) {
m := newTUIModel(paths.Layout{}, model.DaemonConfig{})
m.daemonPending = false
m.daemonReady = true
m.imagePending = true
m.loading = true
updated, _ := m.updateBrowse(tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune{'c'}})
if updated.mode != tuiModeBrowse {
t.Fatalf("mode = %v, want browse", updated.mode)
}
if updated.statusText != "Images are still loading" {
t.Fatalf("status = %q, want image loading warning", updated.statusText)
}
}
func TestTUIStatusIncludesStageDurationsAfterInitialLoad(t *testing.T) {
initial := newTUIModel(paths.Layout{}, model.DaemonConfig{})
updated, _ := initial.Update(daemonReadyMsg{
generation: initial.loadGeneration,
layout: paths.Layout{SocketPath: "/tmp/bangerd.sock"},
duration: 2400 * time.Millisecond,
})
m := updated.(tuiModel)
updated, _ = m.Update(vmListLoadedMsg{
generation: m.loadGeneration,
vms: []model.VMRecord{},
duration: 20 * time.Millisecond,
})
m = updated.(tuiModel)
updated, _ = m.Update(imageListLoadedMsg{
generation: m.loadGeneration,
images: []model.Image{{Name: "default"}},
duration: 15 * time.Millisecond,
})
m = updated.(tuiModel)
if !strings.Contains(m.statusText, "daemon 2.4s") || !strings.Contains(m.statusText, "vm list 20ms") || !strings.Contains(m.statusText, "image list 15ms") {
t.Fatalf("statusText = %q, want stage timings", m.statusText)
}
}