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
|
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 {
|
||||||
|
|
|
||||||
|
|
@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue