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:
parent
8ba920eda6
commit
3a92362829
2 changed files with 467 additions and 54 deletions
|
|
@ -31,6 +31,17 @@ import (
|
|||
|
||||
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
|
||||
|
||||
const (
|
||||
|
|
@ -52,11 +63,27 @@ const (
|
|||
actionLogs actionKind = "logs"
|
||||
)
|
||||
|
||||
type dataLoadedMsg struct {
|
||||
vms []model.VMRecord
|
||||
images []model.Image
|
||||
focusID string
|
||||
err error
|
||||
type daemonReadyMsg struct {
|
||||
generation int
|
||||
layout paths.Layout
|
||||
cfg model.DaemonConfig
|
||||
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 {
|
||||
|
|
@ -447,9 +474,18 @@ type tuiModel struct {
|
|||
height int
|
||||
ready bool
|
||||
|
||||
loading bool
|
||||
busy string
|
||||
sudoValidated bool
|
||||
loadGeneration int
|
||||
loading 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
|
||||
form *vmForm
|
||||
|
|
@ -507,19 +543,26 @@ func newTUIModel(layout paths.Layout, cfg model.DaemonConfig) tuiModel {
|
|||
|
||||
helpView := help.New()
|
||||
|
||||
return tuiModel{
|
||||
layout: layout,
|
||||
cfg: cfg,
|
||||
table: vmTable,
|
||||
detail: detail,
|
||||
help: helpView,
|
||||
spinner: spin,
|
||||
browseKeys: newBrowseKeyMap(),
|
||||
formKeys: newFormKeyMap(),
|
||||
confirmKeys: newConfirmKeyMap(),
|
||||
loading: true,
|
||||
statusText: "Loading VMs...",
|
||||
model := tuiModel{
|
||||
layout: layout,
|
||||
cfg: cfg,
|
||||
width: 120,
|
||||
height: 32,
|
||||
ready: true,
|
||||
table: vmTable,
|
||||
detail: detail,
|
||||
help: helpView,
|
||||
spinner: spin,
|
||||
browseKeys: newBrowseKeyMap(),
|
||||
formKeys: newFormKeyMap(),
|
||||
confirmKeys: newConfirmKeyMap(),
|
||||
loadGeneration: 1,
|
||||
loading: true,
|
||||
daemonPending: true,
|
||||
statusText: "Starting daemon...",
|
||||
}
|
||||
model.resize()
|
||||
return model
|
||||
}
|
||||
|
||||
func newTUICommand() *cobra.Command {
|
||||
|
|
@ -528,21 +571,16 @@ func newTUICommand() *cobra.Command {
|
|||
Short: "Launch a terminal UI to manage VMs",
|
||||
Args: noArgsUsage("usage: banger tui"),
|
||||
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")
|
||||
}
|
||||
layout, cfg, err := ensureDaemon(cmd.Context())
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
program := tea.NewProgram(newTUIModel(layout, cfg), tea.WithAltScreen())
|
||||
return program.Start()
|
||||
return tuiProgramRunner(newTUIModel(paths.Layout{}, model.DaemonConfig{}))
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
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) {
|
||||
|
|
@ -560,14 +598,34 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.width = msg.Width
|
||||
m.height = msg.Height
|
||||
m.resize()
|
||||
case dataLoadedMsg:
|
||||
m.loading = false
|
||||
if msg.err != nil {
|
||||
m.setStatus(msg.err.Error(), true)
|
||||
case daemonReadyMsg:
|
||||
if msg.generation != m.loadGeneration {
|
||||
break
|
||||
}
|
||||
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
|
||||
}
|
||||
m.images = msg.images
|
||||
m.vms = msg.vms
|
||||
m.vmListDur = msg.duration
|
||||
targetID := m.selectedID
|
||||
if 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.lastRefresh = time.Now()
|
||||
m.rebuildTable()
|
||||
m.setStatus(fmt.Sprintf("Loaded %d VM(s)", len(msg.vms)), false)
|
||||
m.syncLoadingState()
|
||||
m.setLoadStatus(false)
|
||||
if m.selectedID != "" {
|
||||
cmds = append(cmds, fetchStatsCmd(m.layout, m.selectedID))
|
||||
} else {
|
||||
|
|
@ -583,6 +642,21 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
m.statsID = ""
|
||||
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:
|
||||
if msg.id != m.selectedID {
|
||||
break
|
||||
|
|
@ -613,8 +687,7 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
m.setStatus(msg.status, false)
|
||||
if msg.refresh {
|
||||
m.loading = true
|
||||
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, msg.focusID))
|
||||
cmds = append(cmds, m.beginRefreshLoad(msg.focusID)...)
|
||||
}
|
||||
case externalPreparedMsg:
|
||||
if msg.err != nil {
|
||||
|
|
@ -646,9 +719,8 @@ func (m tuiModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|||
}
|
||||
case refreshTickMsg:
|
||||
cmds = append(cmds, refreshTickCmd())
|
||||
if m.busy == "" && m.mode == tuiModeBrowse {
|
||||
m.loading = true
|
||||
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID))
|
||||
if m.busy == "" && m.mode == tuiModeBrowse && m.daemonReady && !m.vmListPending && !m.imagePending {
|
||||
cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...)
|
||||
}
|
||||
case tea.KeyMsg:
|
||||
switch m.mode {
|
||||
|
|
@ -679,9 +751,16 @@ func (m tuiModel) updateBrowse(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
|
|||
case key.Matches(msg, m.browseKeys.help):
|
||||
m.help.ShowAll = !m.help.ShowAll
|
||||
case key.Matches(msg, m.browseKeys.refresh):
|
||||
m.loading = true
|
||||
cmds = append(cmds, m.spinner.Tick, fetchDataCmd(m.layout, m.selectedID))
|
||||
if !m.daemonReady {
|
||||
cmds = append(cmds, m.beginBootstrapLoad()...)
|
||||
} else {
|
||||
cmds = append(cmds, m.beginRefreshLoad(m.selectedID)...)
|
||||
}
|
||||
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 {
|
||||
m.setStatus("Create requires at least one image", true)
|
||||
return m, cmds
|
||||
|
|
@ -827,9 +906,6 @@ func (m tuiModel) updateConfirmDelete(msg tea.KeyMsg) (tuiModel, []tea.Cmd) {
|
|||
}
|
||||
|
||||
func (m tuiModel) View() string {
|
||||
if !m.ready {
|
||||
return "Loading..."
|
||||
}
|
||||
header := m.renderHeader()
|
||||
body := m.renderBody()
|
||||
status := m.renderStatus()
|
||||
|
|
@ -876,16 +952,20 @@ func (m tuiModel) bodyHeight() int {
|
|||
|
||||
func (m tuiModel) renderHeader() string {
|
||||
status := "idle"
|
||||
if m.loading {
|
||||
status = m.spinner.View() + " loading"
|
||||
} else if m.busy != "" {
|
||||
if m.busy != "" {
|
||||
status = m.spinner.View() + " " + m.busy
|
||||
} else if phase := m.loadingPhase(); phase != "" {
|
||||
status = m.spinner.View() + " " + phase
|
||||
}
|
||||
refreshed := "never"
|
||||
if !m.lastRefresh.IsZero() {
|
||||
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)
|
||||
}
|
||||
|
||||
|
|
@ -909,7 +989,7 @@ func (m tuiModel) renderBody() string {
|
|||
|
||||
leftContent := m.table.View()
|
||||
if len(m.vms) == 0 {
|
||||
leftContent = "No VMs.\n\nPress c to create one."
|
||||
leftContent = m.vmListPlaceholder()
|
||||
}
|
||||
leftPanel := lipgloss.NewStyle().
|
||||
Border(lipgloss.NormalBorder()).
|
||||
|
|
@ -979,7 +1059,7 @@ func (m *tuiModel) rebuildTable() {
|
|||
func (m *tuiModel) refreshDetail() {
|
||||
vm, ok := m.selectedVM()
|
||||
if !ok {
|
||||
m.detail.SetContent("No VM selected.\n\nUse c to create a VM.")
|
||||
m.detail.SetContent(m.detailPlaceholder())
|
||||
return
|
||||
}
|
||||
stats := vm.Stats
|
||||
|
|
@ -1031,6 +1111,158 @@ func (m *tuiModel) setStatus(text string, isErr bool) {
|
|||
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) {
|
||||
for _, vm := range m.vms {
|
||||
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 {
|
||||
start := time.Now()
|
||||
vms, err := rpc.Call[api.VMListResult](context.Background(), layout.SocketPath, "vm.list", api.Empty{})
|
||||
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{})
|
||||
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
|
||||
}
|
||||
|
||||
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 {
|
||||
names := make([]string, 0, len(images))
|
||||
for _, image := range images {
|
||||
|
|
|
|||
|
|
@ -1,9 +1,15 @@
|
|||
package cli
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"banger/internal/model"
|
||||
"banger/internal/paths"
|
||||
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
)
|
||||
|
||||
func TestCreateVMFormSubmit(t *testing.T) {
|
||||
|
|
@ -87,3 +93,143 @@ func TestResolveSelectedID(t *testing.T) {
|
|||
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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue