diff --git a/internal/cli/tui.go b/internal/cli/tui.go index 67a9f13..d66e024 100644 --- a/internal/cli/tui.go +++ b/internal/cli/tui.go @@ -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 { diff --git a/internal/cli/tui_test.go b/internal/cli/tui_test.go index 092c48f..1008b8c 100644 --- a/internal/cli/tui_test.go +++ b/internal/cli/tui_test.go @@ -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) + } +}