From 1f20b336ba2c9fb7d6e94b87fc696227edfa8332 Mon Sep 17 00:00:00 2001 From: zAbuQasem Date: Mon, 15 Jun 2026 14:43:51 +0300 Subject: [PATCH 1/4] feat(tui): add interactive terminal UI (`labctl tui`) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A Bubble Tea TUI to manage and launch iximiuz Labs playgrounds: - Catalog / Playgrounds / Persisted tabs with a live filter and k9s-style framing, theme picker (live preview + skin.yaml overrides), info and shortcuts popups. - Playground actions: ssh, start/stop toggle, restart, persist, extend lifetime, destroy — with confirmation dialogs. - Sign-in flow: auth popup, browser login, and a "Signed in as " confirmation. - Launch by default via the LABCTL_TUI env var; otherwise available as the `labctl tui` subcommand. --- cmd/tui/tui.go | 1558 +++++++++++++++++++++++++++++++++++++++++++ cmd/tui/tui_test.go | 349 ++++++++++ go.mod | 8 +- go.sum | 26 - main.go | 25 + 5 files changed, 1936 insertions(+), 30 deletions(-) create mode 100644 cmd/tui/tui.go create mode 100644 cmd/tui/tui_test.go diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go new file mode 100644 index 0000000..75a25d6 --- /dev/null +++ b/cmd/tui/tui.go @@ -0,0 +1,1558 @@ +package tui + +import ( + "context" + "fmt" + "os" + "os/exec" + "path/filepath" + "slices" + "strconv" + "strings" + "time" + + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/charmbracelet/x/ansi" + "github.com/dustin/go-humanize" + "github.com/spf13/cobra" + "gopkg.in/yaml.v3" + + "github.com/iximiuz/labctl/api" + "github.com/iximiuz/labctl/internal/browser" + "github.com/iximiuz/labctl/internal/config" + "github.com/iximiuz/labctl/internal/labcli" +) + +func NewCommand(cli labcli.CLI) *cobra.Command { + return &cobra.Command{ + Use: "tui", + Short: "Interactive terminal UI to manage and launch playgrounds", + Args: cobra.NoArgs, + RunE: func(cmd *cobra.Command, args []string) error { + return Run(cli) + }, + } +} + +// Run launches the interactive TUI and blocks until the user exits. +func Run(cli labcli.CLI) error { + skin, name := loadSkin() + initStyles(skin) + m := newModel(cli) + m.theme = name + m.themeIdx = themeIndex(name) + p := tea.NewProgram(m, tea.WithAltScreen()) + _, err := p.Run() + return labcli.WrapStatusError(err) +} + +type viewTab int + +const ( + tabCatalog viewTab = iota + tabPlays + tabPersisted +) + +const tabCount = 3 + +type ( + playsMsg struct{ plays, persisted []*api.Play } + catalogMsg struct{ items []api.Playground } + actionMsg struct{ info string } + errMsg struct{ err error } + tickMsg struct{} + authMsg struct { + ok bool + user string + } + loginDoneMsg struct{ err error } + disarmQuitMsg struct{} + statusClearMsg struct{ seq int } +) + +const statusFlashDuration = 4 * time.Second + +// flash sets a transient status message that auto-clears after a few seconds. +func (m *model) flash(msg string) tea.Cmd { + m.status = msg + m.statusSeq++ + seq := m.statusSeq + return tea.Tick(statusFlashDuration, func(time.Time) tea.Msg { return statusClearMsg{seq} }) +} + +const refreshInterval = 5 * time.Second + +func tick() tea.Cmd { + return tea.Tick(refreshInterval, func(time.Time) tea.Msg { return tickMsg{} }) +} + +type pending struct { + id, name string +} + +// modal is the single active overlay/prompt. Exactly one is active at any time, +// which is what makes this an enum instead of a pile of independent booleans. +type modal uint8 + +const ( + modalNone modal = iota + modalFilter // footer search bar (renders within mainView) + modalExtend // lifetime dialog + modalAuth // sign-in popup + modalConfirm // destroy confirmation + modalQuit // quit confirmation + modalInfo // details popup + modalThemes // theme picker (live preview) + modalHelp // shortcuts popup +) + +type model struct { + cli labcli.CLI + + tab viewTab + playsTable table.Model + persistedTable table.Model + catalogTable table.Model + + plays []*api.Play // full, unfiltered + persisted []*api.Play // persistent plays (ListPlays{Persistent:true}) + catalog []api.Playground // full, unfiltered + filteredPlays []*api.Play // rows currently shown (cursor indexes this) + filteredPers []*api.Play + filteredCat []api.Playground + + modal modal // the single active overlay/prompt + + filter string + input textinput.Model // filter + extend lifetime field + extendBtn int // 0 = field, 1 = Cancel, 2 = OK + extendID string // play being extended + + status string + statusSeq int // bumped per flash; stale auto-clears are ignored + confirm pending // destroy target (valid while modal == modalConfirm) + confirmBtn int // 0 = Cancel, 1 = Destroy + authBtn int // 0 = Dismiss, 1 = Login via browser + authDismissed bool // auth popup already dismissed this session + quitBtn int // 0 = Cancel, 1 = Quit + themePrevIdx int // theme to restore if the picker is cancelled + quitArmed bool // first ctrl+c/ctrl+d seen; a second one quits + theme string // active color theme name + themeIdx int // index into themeOrder for the T-key cycler + user string // logged-in user id (from GetMe) + defaulted bool // initial view default (catalog-if-empty) applied + width, height int +} + +func newModel(cli labcli.CLI) model { + pt := table.New(table.WithFocused(true)) + pp := table.New() + ct := table.New() + applySkin(&pt) + applySkin(&pp) + applySkin(&ct) + ti := textinput.New() + ti.Prompt = "" + m := model{cli: cli, tab: tabPlays, playsTable: pt, persistedTable: pp, catalogTable: ct, input: ti, status: "Loading...", theme: "k9s"} + m.setSizes(80, 24) + return m +} + +func (m model) Init() tea.Cmd { + return tea.Batch(m.loadPlays(), m.loadCatalog(), m.checkAuth(), tick()) +} + +func (m model) checkAuth() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + me, err := m.cli.Client().GetMe(ctx) + if err != nil { + return authMsg{ok: false} + } + return authMsg{ok: true, user: me.ID} + } +} + +// loginCmd hands the terminal to `labctl auth login` (browser flow), then +// resumes the TUI. +func (m model) loginCmd() tea.Cmd { + c := exec.Command(os.Args[0], "auth", "login") + return tea.ExecProcess(c, func(err error) tea.Msg { + return loginDoneMsg{err} + }) +} + +// ponytail: bubbletea cmds run off the UI goroutine; each opens its own context. +func (m model) loadPlays() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + recent, err := m.cli.Client().ListPlays(ctx, api.ListPlaysQueryParams{}) + if err != nil { + return errMsg{err} + } + persistent, err := m.cli.Client().ListPlays(ctx, api.ListPlaysQueryParams{Persistent: true}) + if err != nil { + return errMsg{err} + } + gone := func(p *api.Play) bool { return !p.IsActive() && !p.StateIs(api.StateStopped) } + byUpdated := func(a, b *api.Play) int { return strings.Compare(b.UpdatedAt, a.UpdatedAt) } + + isPersistent := make(map[string]bool, len(persistent)) + for _, p := range persistent { + isPersistent[p.ID] = true + } + + // Playgrounds: active/stopped, non-persistent (persistent labs live only + // on the Persisted tab). + plays := slices.DeleteFunc(append([]*api.Play{}, recent...), func(p *api.Play) bool { + return gone(p) || isPersistent[p.ID] + }) + slices.SortFunc(plays, byUpdated) + + // Persisted: the persistent labs. + persisted := slices.DeleteFunc(append([]*api.Play{}, persistent...), gone) + slices.SortFunc(persisted, byUpdated) + + return playsMsg{plays: plays, persisted: persisted} + } +} + +func (m model) loadCatalog() tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + items, err := m.cli.Client().ListPlaygrounds(ctx, &api.ListPlaygroundsOptions{}) + if err != nil { + return errMsg{err} + } + slices.SortFunc(items, func(a, b api.Playground) int { return strings.Compare(a.Name, b.Name) }) + return catalogMsg{items} + } +} + +func (m model) stopPlay(id string) tea.Cmd { + return m.playAction("Stopped "+id, func(ctx context.Context) error { + _, err := m.cli.Client().StopPlay(ctx, id) + return err + }) +} + +func (m model) restartPlay(id string) tea.Cmd { + return m.playAction("Restarted "+id, func(ctx context.Context) error { + _, err := m.cli.Client().RestartPlay(ctx, id) + return err + }) +} + +func (m model) destroyPlay(id string) tea.Cmd { + return m.playAction("Destroyed "+id, func(ctx context.Context) error { + return m.cli.Client().DestroyPlay(ctx, id) + }) +} + +func (m model) persistPlay(id string) tea.Cmd { + return m.playAction("Persisted "+id, func(ctx context.Context) error { + return m.cli.Client().PersistPlay(ctx, id) + }) +} + +func (m model) extendPlay(id string, minutes int) tea.Cmd { + return m.playAction(fmt.Sprintf("Lifetime of %s set to %dm", id, minutes), func(ctx context.Context) error { + _, err := m.cli.Client().SetPlayMaxPlayTime(ctx, id, minutes) + return err + }) +} + +func (m model) startPlay(name string) tea.Cmd { + return m.playAction("Started "+name, func(ctx context.Context) error { + // ponytail: official catalog playgrounds need no safety consent; auto-ack. + _, err := m.cli.Client().CreatePlay(ctx, api.CreatePlayRequest{ + Playground: name, + SafetyDisclaimerConsent: true, + }) + return err + }) +} + +func (m model) playAction(info string, fn func(context.Context) error) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + if err := fn(ctx); err != nil { + return errMsg{err} + } + return actionMsg{info} + } +} + +// selectedPlay returns the highlighted play on the active plays-like tab. +func (m *model) selectedPlay() *api.Play { + var tbl *table.Model + var rows []*api.Play + switch m.tab { + case tabPlays: + tbl, rows = &m.playsTable, m.filteredPlays + case tabPersisted: + tbl, rows = &m.persistedTable, m.filteredPers + default: + return nil + } + if i := tbl.Cursor(); i >= 0 && i < len(rows) { + return rows[i] + } + return nil +} + +func (m *model) selectedPlayground() *api.Playground { + i := m.catalogTable.Cursor() + if i < 0 || i >= len(m.filteredCat) { + return nil + } + return &m.filteredCat[i] +} + +// filterPlays returns the subset of plays matching f, along with their rows, +// keeping the cursor->slice mapping in sync. +func filterPlays(plays []*api.Play, f string) ([]*api.Play, []table.Row) { + out := make([]*api.Play, 0, len(plays)) + rows := make([]table.Row, 0, len(plays)) + for _, p := range plays { + row := table.Row{p.ID, p.Playground.Name, shortStatus(p), humanize.Time(parseTime(p.CreatedAt))} + if f == "" || strings.Contains(strings.ToLower(strings.Join(row, " ")), f) { + out = append(out, p) + rows = append(rows, row) + } + } + return out, rows +} + +// refreshRows recomputes the filtered slices and table rows from the full lists, +// applying the current filter. +func (m *model) refreshRows() { + f := strings.ToLower(strings.TrimSpace(m.filter)) + + var pr, ppr []table.Row + m.filteredPlays, pr = filterPlays(m.plays, f) + m.playsTable.SetRows(pr) + m.filteredPers, ppr = filterPlays(m.persisted, f) + m.persistedTable.SetRows(ppr) + + m.filteredCat = m.filteredCat[:0] + cr := make([]table.Row, 0, len(m.catalog)) + for _, p := range m.catalog { + row := table.Row{p.Name, p.Description} + if f == "" || strings.Contains(strings.ToLower(strings.Join(row, " ")), f) { + m.filteredCat = append(m.filteredCat, p) + cr = append(cr, row) + } + } + m.catalogTable.SetRows(cr) +} + +func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width, m.height = msg.Width, msg.Height + m.setSizes(msg.Width, msg.Height) + return m, nil + + case playsMsg: + m.plays = msg.plays + m.persisted = msg.persisted + m.refreshRows() + // Clear the transient progress statuses on a healthy poll (action results + // and ✗ errors clear themselves via flash). + if m.status == "Loading..." || m.status == "Refreshing..." { + m.status = "" + } + // Default to the Catalog view once, if the user has no playgrounds. + if !m.defaulted { + m.defaulted = true + if len(m.plays) == 0 { + m.tab = tabCatalog + m.focusActiveTable() + } + } + return m, nil + + case catalogMsg: + m.catalog = msg.items + m.refreshRows() + return m, nil + + case authMsg: + fromLogin := m.status == "Signing in..." + if fromLogin { + m.status = "" + } + if msg.ok { + m.user = msg.user + m.authDismissed = false + cmds := []tea.Cmd{m.loadPlays(), m.loadCatalog()} + if fromLogin { // confirm the sign-in, then auto-clear + note := okMark + " Signed in" + if msg.user != "" { + note += " as " + msg.user + } + cmds = append(cmds, m.flash(note)) + } + return m, tea.Batch(cmds...) + } + if !m.authDismissed { + m.modal = modalAuth + m.authBtn = 1 // default-highlight Login + } + return m, nil + + case loginDoneMsg: + if msg.err != nil { + m.status = errMark + " Login failed: " + msg.err.Error() + return m, nil + } + // Reload credentials on the UI goroutine (the login subprocess wrote them + // to disk) so we don't mutate the shared client from a background cmd. + if home, err := os.UserHomeDir(); err == nil { + if cfg, err := config.Load(home); err == nil { + m.cli.Config().SessionID = cfg.SessionID + m.cli.Config().AccessToken = cfg.AccessToken + m.cli.Client().SetCredentials(cfg.SessionID, cfg.AccessToken) + } + } + m.status = "Signing in..." + return m, m.checkAuth() + + case disarmQuitMsg: + m.quitArmed = false + if m.status == quitHint { + m.status = "" + } + return m, nil + + case statusClearMsg: + if msg.seq == m.statusSeq { + m.status = "" + } + return m, nil + + case tickMsg: + return m, tea.Batch(m.loadPlays(), tick()) + + case actionMsg: + clear := m.flash(okMark + " " + msg.info) + return m, tea.Batch(m.loadPlays(), clear) + + case errMsg: + clear := m.flash(errMark + " " + msg.err.Error()) + return m, clear + + case tea.KeyMsg: + return m.handleKey(msg) + } + + cmd := m.delegate(msg) + return m, cmd +} + +const ( + quitHint = "Press ctrl+c again to exit" + okMark = "✓" + errMark = "✗" +) + +// Fixed (theme-independent) status icon colors. +var ( + okStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#3FB950")).Bold(true) + errStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#F85149")).Bold(true) +) + +// renderStatus colors the leading ✓/✗ icon green/red, keeping the rest themed. +func renderStatus(s string) string { + switch { + case strings.HasPrefix(s, okMark): + return okStyle.Render(okMark) + statusStyle.Render(strings.TrimPrefix(s, okMark)) + case strings.HasPrefix(s, errMark): + return errStyle.Render(errMark) + statusStyle.Render(strings.TrimPrefix(s, errMark)) + default: + return statusStyle.Render(s) + } +} + +func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + // Double ctrl+c / ctrl+d to quit (Claude-CLI style): first press arms and + // hints, a second within the window quits. Works in every mode. + if s := msg.String(); s == "ctrl+c" || s == "ctrl+d" { + if m.quitArmed { + return m, tea.Quit + } + m.quitArmed = true + m.status = quitHint + return m, tea.Tick(2*time.Second, func(time.Time) tea.Msg { return disarmQuitMsg{} }) + } + m.quitArmed = false // any other key disarms + + // An active modal captures all input until dismissed. + switch m.modal { + case modalThemes: + return m.handleThemesKey(msg) + case modalExtend: + return m.handleExtendSelectKey(msg) + case modalFilter: + return m.handlePromptKey(msg) + case modalQuit: + return m.handleQuitKey(msg) + case modalInfo: + return m.handleInfoKey(msg) + case modalAuth: + return m.handleAuthKey(msg) + case modalConfirm: + return m.handleConfirmKey(msg) + case modalHelp: // any key closes it + m.modal = modalNone + return m, nil + } + + switch msg.String() { + case "q": + m.modal = modalQuit + m.quitBtn = 0 + return m, nil + + case ":", "/": // open the filter/search prompt (k9s-style) + m.modal = modalFilter + m.input.SetValue(m.filter) + m.input.Placeholder = "filter..." + m.input.CursorEnd() + return m, m.input.Focus() + + case "tab", "right", "l": + m.switchTab(1) + return m, nil + + case "shift+tab", "left", "h": + m.switchTab(-1) + return m, nil + + case "r": + m.status = "Refreshing..." + return m, tea.Batch(m.loadPlays(), m.loadCatalog()) + + case "?": // shortcuts popup + m.modal = modalHelp + return m, nil + + case "T": // open the theme picker (live preview) + m.modal = modalThemes + m.themePrevIdx = m.themeIdx + return m, nil + + case "i": // show full details of the selected row + if (m.tab == tabCatalog && m.selectedPlayground() != nil) || + (m.tab != tabCatalog && m.selectedPlay() != nil) { + m.modal = modalInfo + } + return m, nil + } + + if m.tab == tabCatalog { + return m.handleCatalogKey(msg) + } + return m.handlePlaysKey(msg) // tabPlays + tabPersisted share actions +} + +// switchTab moves to the next/previous tab and focuses its table. +func (m *model) switchTab(delta int) { + m.tab = viewTab((int(m.tab) + delta + tabCount) % tabCount) + m.focusActiveTable() +} + +func (m *model) focusActiveTable() { + m.playsTable.Blur() + m.persistedTable.Blur() + m.catalogTable.Blur() + switch m.tab { + case tabPersisted: + m.persistedTable.Focus() + case tabCatalog: + m.catalogTable.Focus() + default: + m.playsTable.Focus() + } +} + +// handleThemesKey drives the theme picker: arrows live-preview, enter keeps, esc +// reverts to the theme active when it was opened. +func (m model) handleThemesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "k", "left", "h": + m.themeIdx = (m.themeIdx - 1 + len(themeOrder)) % len(themeOrder) + m.applyTheme(themeOrder[m.themeIdx]) + return m, nil + case "down", "j", "right", "l", "tab": + m.themeIdx = (m.themeIdx + 1) % len(themeOrder) + m.applyTheme(themeOrder[m.themeIdx]) + return m, nil + case "enter": + m.modal = modalNone + return m, m.flash("Theme: " + m.theme) + case "esc": + m.themeIdx = m.themePrevIdx + m.applyTheme(themeOrder[m.themeIdx]) + m.modal = modalNone + return m, nil + default: + return m, nil + } +} + +func (m model) handleQuitKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "down", "left", "right", "tab", "j", "k", "h", "l": + m.quitBtn = 1 - m.quitBtn + return m, nil + case "esc": + m.modal = modalNone + m.quitBtn = 0 + return m, nil + case "enter": + if m.quitBtn == 1 { + return m, tea.Quit + } + m.modal = modalNone + m.quitBtn = 0 + return m, nil + default: + return m, nil + } +} + +// handleInfoKey closes the details popup on any key; 'o' opens the page first. +func (m model) handleInfoKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "o" { + if url := m.selectedURL(); url != "" { + _ = browser.Open(url) + } + return m, nil + } + m.modal = modalNone + return m, nil +} + +func (m model) handleAuthKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "down", "left", "right", "tab", "j", "k", "h", "l": + m.authBtn = 1 - m.authBtn + return m, nil + case "esc": + m.modal = modalNone + m.authDismissed = true + return m, nil + case "enter": + m.modal = modalNone + if m.authBtn == 1 { + m.status = "Opening browser for login..." + return m, m.loginCmd() + } + m.authDismissed = true + return m, nil + default: + return m, nil + } +} + +func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "down", "left", "right", "tab", "j", "k", "h", "l": + m.confirmBtn = 1 - m.confirmBtn + return m, nil + case "enter": + if m.confirmBtn == 1 { + id := m.confirm.id + m.modal = modalNone + m.confirmBtn = 0 + m.status = "Destroying..." + return m, m.destroyPlay(id) + } + fallthrough + case "esc": + m.modal = modalNone + m.confirmBtn = 0 + m.status = "Cancelled" + return m, nil + default: + return m, nil + } +} + +// handlePromptKey drives the filter input (live-filters as you type). +func (m model) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.filter = "" + m.refreshRows() + m.modal = modalNone + m.input.Blur() + return m, nil + case "enter": + m.filter = m.input.Value() + m.refreshRows() + m.modal = modalNone + m.input.Blur() + return m, nil + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + m.filter = m.input.Value() + m.refreshRows() + return m, cmd + } +} + +func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + p := m.selectedPlay() + switch msg.String() { + case "enter": // SSH by handing the terminal to `labctl ssh `. + if p == nil || !p.IsActive() { + m.status = errMark + " Select a running playground to SSH" + return m, nil + } + c := exec.Command(os.Args[0], "ssh", p.ID) + return m, tea.ExecProcess(c, func(err error) tea.Msg { + if err != nil { + return errMsg{err} + } + return actionMsg{"SSH session ended"} + }) + case "o": + if p == nil { + return m, nil + } + if err := browser.Open(p.PageURL); err != nil { + m.status = errMark + " Error opening browser: " + err.Error() + } + return m, nil + case "s": // toggle: stop a running playground, start a stopped one + if p == nil { + return m, nil + } + if p.StateIs(api.StateStopped) { + m.status = "Starting..." + return m, m.restartPlay(p.ID) + } + m.status = "Stopping..." + return m, m.stopPlay(p.ID) + case "t": + if p == nil { + return m, nil + } + m.status = "Restarting..." + return m, m.restartPlay(p.ID) + case "P": // make the playground persistent (Playgrounds tab, active labs only) + if m.tab != tabPlays { + return m, nil // already persistent / not a playground + } + if p == nil || !p.IsActive() { + m.status = errMark + " Select a running playground to persist" + return m, nil + } + m.status = "Persisting..." + return m, m.persistPlay(p.ID) + case "e": // extend lifetime (opens the lifetime dialog) + if p == nil || !p.IsActive() { + m.status = errMark + " Select a running playground to extend" + return m, nil + } + m.modal = modalExtend + m.extendBtn = 0 + m.extendID = p.ID + m.input.SetValue("") + m.input.Placeholder = "90m, 3h" + return m, m.input.Focus() + case "x": + if p == nil { + return m, nil + } + m.confirm = pending{id: p.ID, name: p.Playground.Name} + m.confirmBtn = 0 // default-highlight Cancel for a destructive action + m.modal = modalConfirm + m.status = "" + return m, nil + } + cmd := m.delegate(msg) + return m, cmd +} + +func (m model) handleCatalogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + if msg.String() == "enter" { + pg := m.selectedPlayground() + if pg == nil { + return m, nil + } + m.tab = tabPlays + m.focusActiveTable() + m.status = "Starting " + pg.Name + "..." + return m, m.startPlay(pg.Name) + } + cmd := m.delegate(msg) + return m, cmd +} + +// delegate forwards navigation keys to the focused table, persisting its +// updated state (cursor position) back onto the model. +func (m *model) delegate(msg tea.Msg) tea.Cmd { + var cmd tea.Cmd + switch m.tab { + case tabPersisted: + m.persistedTable, cmd = m.persistedTable.Update(msg) + case tabCatalog: + m.catalogTable, cmd = m.catalogTable.Update(msg) + default: + m.playsTable, cmd = m.playsTable.Update(msg) + } + return cmd +} + +func (m *model) setSizes(w, h int) { + // Reserve rows: header(4) + gap(2) + box border(2) + footer(1) + table top + // gap(1). Fixed so the table is the same height on both tabs. + bodyH := h - headerRows - headerGap - 3 - tableGap + if bodyH < 3 { + bodyH = 3 + } + m.playsTable.SetHeight(bodyH) + m.persistedTable.SetHeight(bodyH) + m.catalogTable.SetHeight(bodyH) + + inner := w - 2 // titledBox eats one column per side + idW, nameW, statusW := 26, 18, 26 + ageW := inner - idW - nameW - statusW - 6 + if ageW < 10 { + ageW = 10 + } + playCols := []table.Column{ + {Title: "ID", Width: idW}, + {Title: "NAME", Width: nameW}, + {Title: "STATUS", Width: statusW}, + {Title: "AGE", Width: ageW}, + } + m.playsTable.SetColumns(playCols) + m.playsTable.SetWidth(inner) + m.persistedTable.SetColumns(playCols) + m.persistedTable.SetWidth(inner) + + cNameW := 22 + descW := inner - cNameW - 4 + if descW < 20 { + descW = 20 + } + m.catalogTable.SetColumns([]table.Column{ + {Title: "PLAYGROUND", Width: cNameW}, + {Title: "DESCRIPTION", Width: descW}, + }) + m.catalogTable.SetWidth(inner) +} + +func shortStatus(p *api.Play) string { + st := p.State() + if st == "" { + return "UNKNOWN" + } + if p.StateIs(api.StateRunning) { + return "RUNNING " + humanize.Time(time.Now().Add(time.Duration(p.ExpiresIn)*time.Millisecond)) + } + return string(st) +} + +func parseTime(s string) time.Time { + t, _ := time.Parse(time.RFC3339, s) + return t +} + +// Skin is the color scheme. Any field is a hex code (#RRGGBB) or ANSI-256 +// number. Pick a built-in theme and/or override fields via skin.yaml next to +// the labctl config (~/.iximiuz/labctl/skin.yaml): +// +// theme: dracula # one of: k9s dracula nord gruvbox solarized catppuccin +// border: "#ff00ff" # optional per-field overrides on top of the theme +type Skin struct { + Cells string `yaml:"cells"` // table cell text + Border string `yaml:"border"` // frame borders, hotkeys, focused button + Logo string `yaml:"logo"` // logo, status line, active breadcrumb + Body string `yaml:"body"` // body text, help, info values + Header string `yaml:"header"` // table header, menu labels, dialog titles + Button string `yaml:"button"` // unfocused dialog button bg + SelFg string `yaml:"selFg"` // selected-row foreground + SelBg string `yaml:"selBg"` // selected-row background +} + +// presets are well-known terminal color schemes. "k9s" is the default. +var presets = map[string]Skin{ + "k9s": {Cells: "#00FFFF", Border: "#1E90FF", Logo: "#FFA500", Body: "#5F9EA0", Header: "#FFFFFF", Button: "#483D8B", SelFg: "#FFFFFF", SelBg: "#005F87"}, + "dracula": {Cells: "#F8F8F2", Border: "#BD93F9", Logo: "#FFB86C", Body: "#6272A4", Header: "#F8F8F2", Button: "#44475A", SelFg: "#F8F8F2", SelBg: "#44475A"}, + "nord": {Cells: "#D8DEE9", Border: "#81A1C1", Logo: "#EBCB8B", Body: "#616E88", Header: "#ECEFF4", Button: "#434C5E", SelFg: "#ECEFF4", SelBg: "#3B4252"}, + "gruvbox": {Cells: "#EBDBB2", Border: "#83A598", Logo: "#FE8019", Body: "#928374", Header: "#FBF1C7", Button: "#3C3836", SelFg: "#FBF1C7", SelBg: "#504945"}, + "solarized": {Cells: "#93A1A1", Border: "#268BD2", Logo: "#CB4B16", Body: "#586E75", Header: "#FDF6E3", Button: "#073642", SelFg: "#FDF6E3", SelBg: "#0A4B55"}, + "catppuccin": {Cells: "#CDD6F4", Border: "#89B4FA", Logo: "#FAB387", Body: "#A6ADC8", Header: "#CDD6F4", Button: "#45475A", SelFg: "#1E1E2E", SelBg: "#89B4FA"}, + "github-dark": {Cells: "#C9D1D9", Border: "#58A6FF", Logo: "#D29922", Body: "#8B949E", Header: "#F0F6FC", Button: "#21262D", SelFg: "#F0F6FC", SelBg: "#1F6FEB"}, + "tango-dark": {Cells: "#D3D7CF", Border: "#729FCF", Logo: "#FCAF3E", Body: "#888A85", Header: "#EEEEEC", Button: "#555753", SelFg: "#EEEEEC", SelBg: "#204A87"}, + + // VS Code built-in themes. + "dark-plus": {Cells: "#D4D4D4", Border: "#569CD6", Logo: "#CE9178", Body: "#858585", Header: "#FFFFFF", Button: "#264F78", SelFg: "#FFFFFF", SelBg: "#264F78"}, + "light-plus": {Cells: "#1F1F1F", Border: "#0000FF", Logo: "#A31515", Body: "#6E6E6E", Header: "#000000", Button: "#ADD6FF", SelFg: "#000000", SelBg: "#ADD6FF"}, + "monokai": {Cells: "#F8F8F2", Border: "#66D9EF", Logo: "#FD971F", Body: "#75715E", Header: "#F8F8F2", Button: "#49483E", SelFg: "#F8F8F2", SelBg: "#49483E"}, + "abyss": {Cells: "#6688CC", Border: "#2277FF", Logo: "#FF9900", Body: "#406385", Header: "#FFFFFF", Button: "#103050", SelFg: "#FFFFFF", SelBg: "#103050"}, + "kimbie-dark": {Cells: "#D3AF86", Border: "#98676A", Logo: "#F79A32", Body: "#A57A4C", Header: "#FBEBD4", Button: "#51412C", SelFg: "#FBEBD4", SelBg: "#5E452B"}, + "red": {Cells: "#F8F8F8", Border: "#FB9FB1", Logo: "#FFB454", Body: "#C5808F", Header: "#FFFFFF", Button: "#86181D", SelFg: "#FFFFFF", SelBg: "#86181D"}, + "tomorrow-night": {Cells: "#CCCCCC", Border: "#81A2BE", Logo: "#DE935F", Body: "#969896", Header: "#FFFFFF", Button: "#373B41", SelFg: "#FFFFFF", SelBg: "#373B41"}, +} + +// themeOrder is the cycle/selection order for the in-TUI theme switcher. +var themeOrder = []string{ + "k9s", "dracula", "nord", "gruvbox", "solarized", "catppuccin", "github-dark", "tango-dark", + "dark-plus", "light-plus", "monokai", "abyss", "kimbie-dark", "red", "tomorrow-night", +} + +func themeIndex(name string) int { + for i, n := range themeOrder { + if n == name { + return i + } + } + return 0 +} + +// loadSkin resolves the theme name and overlays any per-field overrides from +// skin.yaml. Best-effort: a missing/invalid file just yields the k9s default. +func loadSkin() (Skin, string) { + name := "k9s" + home, err := os.UserHomeDir() + if err != nil { + return presets[name], name + } + path := filepath.Join(filepath.Dir(config.ConfigFilePath(home)), "skin.yaml") + data, err := os.ReadFile(path) + if err != nil { + return presets[name], name + } + + var head struct { + Theme string `yaml:"theme"` + } + _ = yaml.Unmarshal(data, &head) + if _, ok := presets[head.Theme]; ok { + name = head.Theme + } + + s := presets[name] + _ = yaml.Unmarshal(data, &s) // overlay explicit field overrides + return s, name +} + +var ( + cCells, cBorder, cLogo, cBody, cHeader, cButton, cSelFg, cSelBg lipgloss.Color + + statusStyle, helpStyle, infoKey, infoVal, menuKey, menuText, logoStyle lipgloss.Style + dialogTitle, btnSel, btnUnsel lipgloss.Style +) + +func init() { initStyles(presets["k9s"]) } + +func initStyles(s Skin) { + cCells = lipgloss.Color(s.Cells) + cBorder = lipgloss.Color(s.Border) + cLogo = lipgloss.Color(s.Logo) + cBody = lipgloss.Color(s.Body) + cHeader = lipgloss.Color(s.Header) + cButton = lipgloss.Color(s.Button) + cSelFg = lipgloss.Color(s.SelFg) + cSelBg = lipgloss.Color(s.SelBg) + + statusStyle = lipgloss.NewStyle().Foreground(cLogo) + helpStyle = lipgloss.NewStyle().Foreground(cBody) + infoKey = lipgloss.NewStyle().Foreground(cBorder) + infoVal = lipgloss.NewStyle().Foreground(cBody) + menuKey = lipgloss.NewStyle().Foreground(cBorder) + menuText = lipgloss.NewStyle().Foreground(cHeader) + logoStyle = lipgloss.NewStyle().Foreground(cLogo).Bold(true) + + dialogTitle = lipgloss.NewStyle().Foreground(cBorder).Bold(true) + btnSel = lipgloss.NewStyle().Padding(0, 2).Background(cBorder).Foreground(lipgloss.Color("#000000")).Bold(true) + btnUnsel = lipgloss.NewStyle().Padding(0, 2).Background(cButton).Foreground(cHeader) +} + +// labctl wordmark shown top-right, k9s-style (rendered in the logo color). +var logoSmall = []string{ + "╻ ┏━┓┏┓ ┏━╸╺┳╸╻ ", + "┃ ┣━┫┣┻┓┃ ┃ ┃ ", + "┗━╸╹ ╹┗━┛┗━╸ ╹ ┗━╸", +} + +func applySkin(t *table.Model) { + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(cBorder). + BorderBottom(true). + Bold(true). + Foreground(cHeader) + s.Selected = s.Selected.Foreground(cSelFg).Background(cSelBg).Bold(true) + s.Cell = s.Cell.Foreground(cCells) + t.SetStyles(s) +} + +func infoLine(k, v string) string { + return infoKey.Render(fmt.Sprintf("%-9s", k+":")) + infoVal.Render(v) +} + +// titledBox draws a k9s-style frame: a colored border with the title embedded +// in the top edge. It normalizes every body line to one uniform inner width +// (truncating/padding, ANSI-aware) so the border stays square regardless of the +// table's internal line widths. maxW caps the frame to the terminal width. +func titledBox(title, body string, maxW int) string { + lines := strings.Split(body, "\n") + innerW := lipgloss.Width(title) + 4 + for _, ln := range lines { + if wdt := lipgloss.Width(ln); wdt > innerW { + innerW = wdt + } + } + if innerW > maxW { + innerW = maxW + } + + bs := lipgloss.NewStyle().Foreground(cBorder) + dashes := innerW - lipgloss.Width(title) - 3 + if dashes < 0 { + dashes = 0 + } + side := bs.Render("│") + + var b strings.Builder + b.WriteString(bs.Render("╭─ ") + title + bs.Render(" "+strings.Repeat("─", dashes)+"╮") + "\n") + for _, ln := range lines { + ln = ansi.Truncate(ln, innerW, "") + pad := innerW - lipgloss.Width(ln) + if pad < 0 { + pad = 0 + } + b.WriteString(side + ln + strings.Repeat(" ", pad) + side + "\n") + } + b.WriteString(bs.Render("╰" + strings.Repeat("─", innerW) + "╯")) + return b.String() +} + +// buttonRow renders k9s-style filled buttons side by side (SetButtonsAlign +// center), highlighting the selected one. selected < 0 highlights none. +func buttonRow(labels []string, selected int) string { + parts := make([]string, 0, len(labels)*2-1) + for i, l := range labels { + if i > 0 { + parts = append(parts, " ") + } + if i == selected { + parts = append(parts, btnSel.Render(l)) + } else { + parts = append(parts, btnUnsel.Render(l)) + } + } + return lipgloss.JoinHorizontal(lipgloss.Center, parts...) +} + +// kDialog renders a roomy k9s-style modal (tview.ModalForm): a bordered box with +// the title embedded in the top edge as "< Title >", message/fields centered, +// and buttons centered at the bottom. +func kDialog(title string, lines []string) string { + innerW := lipgloss.Width("< "+title+" >") + 2 + for _, l := range lines { + if w := lipgloss.Width(l); w > innerW { + innerW = w + } + } + innerW += 12 // generous breathing room + if innerW < 40 { + innerW = 40 + } + + bs := lipgloss.NewStyle().Foreground(cBorder) + side := bs.Render("│") + blank := side + strings.Repeat(" ", innerW) + side + + titleStr := dialogTitle.Render("< " + title + " >") + dash := innerW - lipgloss.Width("< "+title+" >") + ld := dash / 2 + rd := dash - ld + + var b strings.Builder + b.WriteString(bs.Render("╭"+strings.Repeat("─", ld)) + titleStr + bs.Render(strings.Repeat("─", rd)+"╮") + "\n") + b.WriteString(blank + "\n" + blank + "\n") // top padding + for _, l := range lines { + lw := lipgloss.Width(l) + lp := (innerW - lw) / 2 + rp := innerW - lw - lp + if lp < 0 { + lp = 0 + } + if rp < 0 { + rp = 0 + } + b.WriteString(side + strings.Repeat(" ", lp) + l + strings.Repeat(" ", rp) + side + "\n") + } + b.WriteString(blank + "\n" + blank + "\n") // bottom padding + b.WriteString(bs.Render("╰" + strings.Repeat("─", innerW) + "╯")) + return b.String() +} + +// centered draws box on a blank canvas (fully opaque). +func (m model) centered(box string) string { + w, h := m.dims() + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, box) +} + +// overlayCentered composites box centered over the live main view so the table +// behind stays visible (transparent popup). +func (m model) overlayCentered(box string) string { + w, h := m.dims() + x := (w - lipgloss.Width(box)) / 2 + y := (h - lipgloss.Height(box)) / 2 + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + return overlay(m.mainView(), box, x, y) +} + +// dims returns the window size with an 80x24 fallback before the first resize. +func (m model) dims() (int, int) { + if m.width == 0 { + return 80, 24 + } + return m.width, m.height +} + +func (m model) View() string { + switch m.modal { + case modalAuth: + return m.authView() + case modalConfirm: + return m.confirmView() + case modalQuit: + return m.quitView() + case modalExtend: + return m.extendSelectView() + case modalInfo: + return m.infoView() + case modalThemes: + return m.themeView() + case modalHelp: + return m.helpView() + default: // modalNone or modalFilter (filter renders as a footer bar) + return m.mainView() + } +} + +func (m model) mainView() string { + w, _ := m.dims() + + var body string + switch m.tab { + case tabPersisted: + body = m.persistedTable.View() + case tabCatalog: + body = m.catalogTable.View() + default: + body = m.playsTable.View() + } + + var footer string + switch { + case m.modal == modalFilter: + footer = menuKey.Render("/ ") + m.input.View() + case m.filter != "": + footer = menuKey.Render("/" + m.filter) + default: + footer = renderStatus(m.status) + } + + body = strings.Repeat("\n", tableGap) + body // gap between tab title and header row + + return strings.Join([]string{ + m.headerView(w), + "", // headerGap blank row between header and table + titledBox(m.tabsTitle(), body, w-2), + footer, + }, "\n") +} + +// tabsTitle renders the playgrounds/catalog tabs as filled blocks embedded in +// the table's title bar, with the active view's row count. +func (m model) tabsTitle() string { + tab := func(label string, active bool) string { + bg := cCells + if active { + bg = cLogo + } + return lipgloss.NewStyle(). + Background(bg).Foreground(lipgloss.Color("#000000")).Bold(true). + Padding(0, 1).Render(label) + } + var count int + switch m.tab { + case tabPersisted: + count = len(m.filteredPers) + case tabCatalog: + count = len(m.filteredCat) + default: + count = len(m.filteredPlays) + } + suffix := fmt.Sprintf(" [%d]", count) + if m.filter != "" { + suffix += " (/" + m.filter + ")" + } + return tab("catalog", m.tab == tabCatalog) + " " + + tab("playgrounds", m.tab == tabPlays) + " " + + tab("persisted", m.tab == tabPersisted) + dialogTitle.Render(suffix) +} + +// applyTheme switches the active color scheme immediately (used for the live +// theme preview). +func (m *model) applyTheme(name string) { + m.theme = name + initStyles(presets[name]) + applySkin(&m.playsTable) + applySkin(&m.persistedTable) + applySkin(&m.catalogTable) +} + +// overlay composites box onto bg at column x, row y (ANSI-aware), leaving the +// rest of bg visible — a "transparent" popup over the live themed view. +func overlay(bg, box string, x, y int) string { + bgLines := strings.Split(bg, "\n") + boxW := lipgloss.Width(box) + for i, bl := range strings.Split(box, "\n") { + row := y + i + if row < 0 || row >= len(bgLines) { + continue + } + base := bgLines[row] + left := ansi.Truncate(base, x, "") + if lw := lipgloss.Width(left); lw < x { + left += strings.Repeat(" ", x-lw) + } + right := "" + if lipgloss.Width(base) > x+boxW { + right = ansi.TruncateLeft(base, x+boxW, "") + } + bgLines[row] = left + bl + right + } + return strings.Join(bgLines, "\n") +} + +func (m model) themePickerBox() string { + const listW = 13 + rows := make([]string, 0, len(themeOrder)+2) + for i, name := range themeOrder { + label := fmt.Sprintf("%-*s", listW, name) + if i == m.themeIdx { + rows = append(rows, lipgloss.NewStyle().Foreground(cSelFg).Background(cSelBg).Bold(true).Render("▸ "+label)) + } else { + rows = append(rows, lipgloss.NewStyle().Foreground(cBody).Render(" "+label)) + } + } + rows = append(rows, "", helpStyle.Render("↑/↓ preview · enter ok")) + return titledBox(dialogTitle.Render("Theme"), strings.Join(rows, "\n"), 40) +} + +func (m model) themeView() string { + w, h := m.dims() + box := m.themePickerBox() + x := (w - lipgloss.Width(box)) / 2 + y := (h - lipgloss.Height(box)) / 2 + if x < 0 { + x = 0 + } + if y < 0 { + y = 0 + } + return overlay(m.mainView(), box, x, y) +} + +// formField renders a "Label: [input]" row, k9s-style, with the label +// highlighted when its field is focused. +func formField(label string, in textinput.Model, focused bool) string { + lbl := menuText.Render(fmt.Sprintf("%-13s", label)) + if focused { + lbl = btnSel.Render(label) + strings.Repeat(" ", 13-lipgloss.Width(label)) + } + return lbl + " " + in.View() +} + +// handleExtendSelectKey drives the extend form (k9s ModalForm): a Lifetime field +// you type into, plus Cancel/OK buttons. Focus 0=field, 1=Cancel, 2=OK. +func (m model) handleExtendSelectKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.modal = modalNone + m.input.Blur() + return m, nil + case "tab", "down": + m.extendBtn = (m.extendBtn + 1) % 3 + return m, m.focusExtend() + case "up": + m.extendBtn = (m.extendBtn + 2) % 3 + return m, m.focusExtend() + case "left", "right": // toggle between Cancel/OK + switch m.extendBtn { + case 1: + m.extendBtn = 2 + case 2: + m.extendBtn = 1 + } + return m, nil + case "enter": + m.input.Blur() + if m.extendBtn == 1 { // Cancel + m.modal = modalNone + return m, nil + } + val := strings.TrimSpace(m.input.Value()) + d, err := time.ParseDuration(val) + m.modal = modalNone + if err != nil || int(d.Minutes()) < 1 { + m.status = errMark + " Invalid lifetime: " + val + " (try 90m, 3h)" + return m, nil + } + m.status = "Setting lifetime..." + return m, m.extendPlay(m.extendID, int(d.Minutes())) + default: + if m.extendBtn == 0 { + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } + return m, nil + } +} + +func (m *model) focusExtend() tea.Cmd { + if m.extendBtn == 0 { + return m.input.Focus() + } + m.input.Blur() + return nil +} + +func (m model) extendSelectView() string { + btn := -1 + if m.extendBtn >= 1 { + btn = m.extendBtn - 1 + } + return m.overlayCentered(kDialog("Extend lifetime", []string{ + menuText.Render("New total lifetime from start"), + "", + formField("Lifetime:", m.input, m.extendBtn == 0), + "", + buttonRow([]string{"Cancel", "OK"}, btn), + })) +} + +func (m model) quitView() string { + return m.centered(kDialog("Quit", []string{ + menuText.Render("Quit labctl?"), + "", + buttonRow([]string{"Cancel", "Quit"}, m.quitBtn), + })) +} + +func (m *model) selectedURL() string { + if m.tab == tabCatalog { + if pg := m.selectedPlayground(); pg != nil { + return pg.PageURL + } + return "" + } + if p := m.selectedPlay(); p != nil { + return p.PageURL + } + return "" +} + +func infoField(k, v string) string { + if v == "" { + v = "-" + } + return infoKey.Render(fmt.Sprintf("%-12s", k+":")) + infoVal.Render(v) +} + +func (m model) infoView() string { + w, _ := m.dims() + contentW := min(max(w*2/3, 44), 96) - 4 // text width inside the frame + + var title string + var fields []string + var desc string + if m.tab == tabCatalog { + pg := m.selectedPlayground() + if pg == nil { + return "" + } + title = pg.Name + fields = []string{ + infoField("Title", pg.Title), + infoField("Categories", strings.Join(pg.Categories, ", ")), + infoField("Machines", strconv.Itoa(len(pg.Machines))), + infoField("URL", pg.PageURL), + } + desc = pg.Description + } else { + p := m.selectedPlay() + if p == nil { + return "" + } + title = p.Playground.Name + fields = []string{ + infoField("ID", p.ID), + infoField("Title", p.Title), + infoField("Status", shortStatus(p)), + infoField("Created", humanize.Time(parseTime(p.CreatedAt))), + infoField("Lifetime", p.MaxPlayTime), + infoField("Machines", strconv.Itoa(len(p.Machines))), + infoField("URL", p.PageURL), + } + desc = p.Playground.Description + } + + parts := []string{strings.Join(fields, "\n")} + if desc != "" { + wrapped := lipgloss.NewStyle().Width(contentW).Foreground(cBody).Render(desc) + parts = append(parts, "", wrapped) + } + parts = append(parts, "", helpStyle.Render("o open in browser · any key to close")) + + return m.centered(titledBox(dialogTitle.Render(title), strings.Join(parts, "\n"), contentW+2)) +} + +func (m model) headerView(w int) string { + user := m.user + if user == "" { + user = "-" + } + info := lipgloss.JoinVertical(lipgloss.Left, + infoLine("Labs", strings.TrimPrefix(m.cli.Config().BaseURL, "https://")), + infoLine("User", user), + infoLine("Plays", strconv.Itoa(len(m.plays))), + infoLine("Catalog", strconv.Itoa(len(m.catalog))), + ) + logo := logoStyle.Render(strings.Join(logoSmall, "\n")) + left := lipgloss.JoinHorizontal(lipgloss.Top, info, " ", m.menuView()) + gap := w - lipgloss.Width(left) - lipgloss.Width(logo) + if gap < 1 { + gap = 1 + } + header := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", gap), logo) + // Pin the header to exactly headerRows so the table never shifts between tabs. + lines := strings.Split(header, "\n") + for len(lines) < headerRows { + lines = append(lines, "") + } + return strings.Join(lines[:headerRows], "\n") +} + +const ( + headerRows = 4 + headerGap = 1 // blank row between header and table + tableGap = 1 // blank row between the tab title and the table header +) + +// menuView shows only the essential shortcuts (everything else lives in the ? +// popup). Both tabs use the same item count so the header height is stable. +func (m model) menuView() string { + enter := "SSH" + if m.tab == tabCatalog { + enter = "Start" + } + items := [][2]string{ + {"enter", enter}, {"i", "Info"}, {":", "Filter"}, + {"tab", "Switch"}, {"r", "Refresh"}, {"?", "Shortcuts"}, + } + if m.tab == tabPlays { // persist only applies to (non-persistent) playgrounds + items = append(items, [2]string{"P", "Persist"}) + } + half := (len(items) + 1) / 2 + var col1, col2 []string + for i, it := range items { + line := menuKey.Render("<"+it[0]+"> ") + menuText.Render(it[1]) + if i < half { + col1 = append(col1, line) + } else { + col2 = append(col2, line) + } + } + return lipgloss.JoinHorizontal(lipgloss.Top, + lipgloss.JoinVertical(lipgloss.Left, col1...), + " ", + lipgloss.JoinVertical(lipgloss.Left, col2...), + ) +} + +func (m model) helpView() string { + key := func(k, d string) string { + return menuKey.Render(fmt.Sprintf(" %-10s", k)) + menuText.Render(d) + } + hdr := func(s string) string { return logoStyle.Render(s) } + + left := strings.Join([]string{ + hdr("Navigation"), + key("↑/↓ j/k", "move"), + key("g / G", "top / bottom"), + key("tab", "switch view"), + key("/ or :", "filter"), + "", + hdr("Playground"), + key("enter", "ssh"), + key("o", "open in browser"), + key("i", "info"), + key("s", "start/stop toggle"), + key("t", "restart"), + key("P", "persist"), + key("e", "extend lifetime"), + key("x", "destroy"), + }, "\n") + right := strings.Join([]string{ + hdr("Catalog"), + key("enter", "start"), + key("i", "info"), + "", + hdr("General"), + key("r", "refresh"), + key("T", "theme picker"), + key("q", "quit"), + key("?", "close help"), + }, "\n") + + body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) + return m.centered(titledBox(dialogTitle.Render("Shortcuts"), body, 72)) +} + +func (m model) authView() string { + return m.centered(kDialog("Sign in", []string{ + menuText.Render("Not signed in to iximiuz Labs"), + "", + buttonRow([]string{"Dismiss", "Login via browser"}, m.authBtn), + })) +} + +func (m model) confirmView() string { + return m.overlayCentered(kDialog("Confirm", []string{ + menuText.Render("Destroy " + m.confirm.name + "?"), + infoVal.Render(m.confirm.id), + "", + buttonRow([]string{"Cancel", "Destroy"}, m.confirmBtn), + })) +} diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go new file mode 100644 index 0000000..81921ac --- /dev/null +++ b/cmd/tui/tui_test.go @@ -0,0 +1,349 @@ +package tui + +import ( + "bytes" + "io" + "strings" + "testing" + + "github.com/charmbracelet/bubbles/table" + tea "github.com/charmbracelet/bubbletea" + + "github.com/iximiuz/labctl/api" + "github.com/iximiuz/labctl/internal/config" + "github.com/iximiuz/labctl/internal/labcli" +) + +func testCLI() labcli.CLI { + cli := labcli.NewCLI(io.NopCloser(bytes.NewReader(nil)), &bytes.Buffer{}, &bytes.Buffer{}, "test") + cli.SetConfig(config.Default("/tmp")) + return cli +} + +func runeKey(s string) tea.KeyMsg { + return tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune(s)} +} + +func playWithState(id string, st api.PlayState) *api.Play { + return &api.Play{ + ID: id, + Playground: api.Playground{Name: "pg"}, + Status: &api.PlayStatus{StateEvents: []api.StateEvent{{State: st}}}, + } +} + +// TestDelegateMovesCursor guards the regression where delegate had a value +// receiver and dropped the table's updated cursor, breaking up/down navigation. +func TestDelegateMovesCursor(t *testing.T) { + m := model{tab: tabPlays, playsTable: table.New(table.WithFocused(true))} + m.setSizes(80, 24) + m.playsTable.SetRows([]table.Row{ + {"a", "", "", ""}, + {"b", "", "", ""}, + {"c", "", "", ""}, + }) + + if got := m.playsTable.Cursor(); got != 0 { + t.Fatalf("start cursor = %d, want 0", got) + } + + m.delegate(tea.KeyMsg{Type: tea.KeyDown}) + + if got := m.playsTable.Cursor(); got != 1 { + t.Fatalf("after down, cursor = %d, want 1 (delegate must persist table state)", got) + } +} + +// TestViewRenders smoke-tests the k9s-style chrome: it must render the titled +// frame, breadcrumbs, and logo without panicking on a fresh (empty) model. +func TestViewRenders(t *testing.T) { + cli := labcli.NewCLI(io.NopCloser(bytes.NewReader(nil)), &bytes.Buffer{}, &bytes.Buffer{}, "test") + cli.SetConfig(config.Default("/tmp")) + + m := newModel(cli) + m.width, m.height = 100, 30 + m.setSizes(100, 30) + + out := m.View() + for _, want := range []string{"playgrounds", "catalog", "[0]", "User:"} { + if !strings.Contains(out, want) { + t.Fatalf("View() missing %q\n---\n%s", want, out) + } + } +} + +// TestDoubleQuit guards the Claude-CLI-style quit: one ctrl+c arms (no quit), +// a second one actually quits. +func TestDoubleQuit(t *testing.T) { + m := newModel(testCLI()) + ctrlC := tea.KeyMsg{Type: tea.KeyCtrlC} + + m1, _ := m.handleKey(ctrlC) + if !m1.(model).quitArmed { + t.Fatal("first ctrl+c should arm quit, not quit immediately") + } + + _, cmd := m1.(model).handleKey(ctrlC) + if cmd == nil { + t.Fatal("second ctrl+c should return a quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("second ctrl+c cmd = %T, want tea.QuitMsg", cmd()) + } +} + +// TestFilterMapping guards that filtering keeps selectedPlay pointing at the +// right play (cursor indexes the filtered slice, not the full list). +func TestFilterMapping(t *testing.T) { + m := newModel(testCLI()) + m.plays = []*api.Play{ + {ID: "aaa", Playground: api.Playground{Name: "docker"}}, + {ID: "bbb", Playground: api.Playground{Name: "k3s"}}, + } + m.filter = "k3s" + m.refreshRows() + + if len(m.filteredPlays) != 1 { + t.Fatalf("filtered len = %d, want 1", len(m.filteredPlays)) + } + if p := m.selectedPlay(); p == nil || p.ID != "bbb" { + t.Fatalf("selectedPlay = %v, want play bbb", p) + } +} + +// TestStatusFlashClears guards that an action status auto-clears, and that a +// stale clear (superseded by a newer flash) does not wipe the current status. +func TestStatusFlashClears(t *testing.T) { + m := newModel(testCLI()) + + cmd := m.flash("Destroyed xyz") + if m.status != "Destroyed xyz" { + t.Fatalf("status = %q, want flash text", m.status) + } + cleared, _ := m.Update(cmd()) // fire the scheduled clear + if s := cleared.(model).status; s != "" { + t.Fatalf("status after clear = %q, want empty", s) + } + + stale := m.flash("first") // seq N + m.flash("second") // seq N+1, status="second" + kept, _ := m.Update(stale()) // stale clear for seq N must be ignored + if s := kept.(model).status; s != "second" { + t.Fatalf("stale clear wiped status = %q, want \"second\"", s) + } +} + +// TestQuitConfirm guards that q opens a confirmation popup and only the Quit +// button actually quits. +func TestQuitConfirm(t *testing.T) { + m := newModel(testCLI()) + q := tea.KeyMsg{Type: tea.KeyRunes, Runes: []rune("q")} + + opened, _ := m.handleKey(q) + mo := opened.(model) + if mo.modal != modalQuit { + t.Fatal("q should open the quit-confirmation popup, not quit") + } + + // Cancel (default button) closes without quitting. + enter := tea.KeyMsg{Type: tea.KeyEnter} + cancelled, cmd := mo.handleKey(enter) + if cancelled.(model).modal != modalNone || cmd != nil { + t.Fatal("enter on Cancel should close the popup without quitting") + } + + // Move to Quit, then enter quits. + mo.quitBtn = 1 + _, cmd = mo.handleKey(enter) + if cmd == nil { + t.Fatal("enter on Quit should return a quit command") + } + if _, ok := cmd().(tea.QuitMsg); !ok { + t.Fatalf("quit cmd = %T, want tea.QuitMsg", cmd()) + } +} + +// TestStartStopToggle guards that `s` is state-aware: stop a running playground, +// start a stopped one. +func TestStartStopToggle(t *testing.T) { + t.Parallel() + tests := []struct { + name string + state api.PlayState + wantStatus string + }{ + {name: "running stops", state: api.StateRunning, wantStatus: "Stopping..."}, + {name: "stopped starts", state: api.StateStopped, wantStatus: "Starting..."}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("p1", tt.state)} + m.refreshRows() + + out, cmd := m.handlePlaysKey(runeKey("s")) + if got := out.(model).status; got != tt.wantStatus { + t.Fatalf("status = %q, want %q", got, tt.wantStatus) + } + if cmd == nil { + t.Fatal("toggle should return an action command") + } + }) + } +} + +// TestExtendValidation guards the lifetime parsing in the extend dialog: valid +// durations submit, junk and sub-minute values are rejected with a ✗ status. +func TestExtendValidation(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantPrefix string + }{ + {name: "minutes", input: "90m", wantPrefix: "Setting lifetime..."}, + {name: "hours", input: "3h", wantPrefix: "Setting lifetime..."}, + {name: "not a duration", input: "abc", wantPrefix: errMark}, + {name: "below one minute", input: "30s", wantPrefix: errMark}, + {name: "empty", input: "", wantPrefix: errMark}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.modal = modalExtend + m.extendBtn = 0 // field focused; enter submits + m.extendID = "p1" + m.input.SetValue(tt.input) + + out, _ := m.handleExtendSelectKey(tea.KeyMsg{Type: tea.KeyEnter}) + if got := out.(model).status; !strings.HasPrefix(got, tt.wantPrefix) { + t.Fatalf("input %q: status = %q, want prefix %q", tt.input, got, tt.wantPrefix) + } + }) + } +} + +func TestThemeIndex(t *testing.T) { + t.Parallel() + tests := []struct { + name string + in string + want int + }{ + {name: "default is first", in: "k9s", want: 0}, + {name: "known theme", in: "dracula", want: 1}, + {name: "unknown falls back to 0", in: "nope", want: 0}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := themeIndex(tt.in); got != tt.want { + t.Fatalf("themeIndex(%q) = %d, want %d", tt.in, got, tt.want) + } + }) + } +} + +// TestPersistAction guards that `p` triggers a persist command. +func TestPersistAction(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("p1", api.StateRunning)} + m.refreshRows() + + out, cmd := m.handlePlaysKey(runeKey("P")) + if got := out.(model).status; got != "Persisting..." { + t.Fatalf("status = %q, want %q", got, "Persisting...") + } + if cmd == nil { + t.Fatal("persist should return a command") + } +} + +// TestPersistOnlyOnPlaygrounds guards that P is a no-op on the Persisted tab +// (persisted labs are already persistent). +func TestPersistOnlyOnPlaygrounds(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.persisted = []*api.Play{playWithState("p1", api.StateRunning)} + m.refreshRows() + m.tab = tabPersisted + + out, _ := m.handlePlaysKey(runeKey("P")) + if got := out.(model).status; got == "Persisting..." { + t.Fatal("persist should be a no-op on the Persisted tab") + } +} + +// TestPersistedTabSelection guards that on the Persisted tab, selectedPlay +// indexes the persisted slice (not the plays slice). +func TestPersistedTabSelection(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("aaa", api.StateRunning)} + m.persisted = []*api.Play{playWithState("bbb", api.StateStopped)} + m.refreshRows() + + m.switchTab(1) // playgrounds -> persisted + if m.tab != tabPersisted { + t.Fatalf("tab = %v, want tabPersisted", m.tab) + } + if p := m.selectedPlay(); p == nil || p.ID != "bbb" { + t.Fatalf("selectedPlay = %v, want persisted play bbb", p) + } +} + +func TestSwitchTabCycles(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + for _, want := range []viewTab{tabPersisted, tabCatalog, tabPlays} { + m.switchTab(1) + if m.tab != want { + t.Fatalf("after +1, tab = %v, want %v", m.tab, want) + } + } + m.switchTab(-1) + if m.tab != tabCatalog { + t.Fatalf("after -1, tab = %v, want tabCatalog", m.tab) + } +} + +// TestSigningInFlash guards that login replaces the stuck "Signing in..." with a +// "Signed in as " confirmation (which then auto-clears via flash). +func TestSigningInFlash(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.status = "Signing in..." + + out, _ := m.Update(authMsg{ok: true, user: "usr_x"}) + got := out.(model).status + if got == "Signing in..." { + t.Fatal(`"Signing in..." should be replaced after auth resolves`) + } + if !strings.HasPrefix(got, okMark) || !strings.Contains(got, "usr_x") { + t.Fatalf("status = %q, want a signed-in flash mentioning the user", got) + } +} + +func TestShortStatus(t *testing.T) { + t.Parallel() + tests := []struct { + name string + play *api.Play + wantPrefix string + }{ + {name: "no status", play: &api.Play{}, wantPrefix: "UNKNOWN"}, + {name: "stopped", play: playWithState("p", api.StateStopped), wantPrefix: "STOPPED"}, + {name: "running", play: playWithState("p", api.StateRunning), wantPrefix: "RUNNING"}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + if got := shortStatus(tt.play); !strings.HasPrefix(got, tt.wantPrefix) { + t.Fatalf("shortStatus = %q, want prefix %q", got, tt.wantPrefix) + } + }) + } +} diff --git a/go.mod b/go.mod index 1d5b4cd..f428803 100644 --- a/go.mod +++ b/go.mod @@ -5,7 +5,11 @@ go 1.25.0 require ( github.com/briandowns/spinner v1.23.2 github.com/cenkalti/backoff/v5 v5.0.3 + github.com/charmbracelet/bubbles v1.0.0 + github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/huh v1.0.0 + github.com/charmbracelet/lipgloss v1.1.0 + github.com/charmbracelet/x/ansi v0.11.7 github.com/docker/cli v29.5.3+incompatible github.com/dustin/go-humanize v1.0.1 github.com/fsnotify/fsnotify v1.10.1 @@ -28,11 +32,7 @@ require ( github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect - github.com/charmbracelet/bubbles v1.0.0 // indirect - github.com/charmbracelet/bubbletea v1.3.10 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect - github.com/charmbracelet/lipgloss v1.1.0 // indirect - github.com/charmbracelet/x/ansi v0.11.7 // indirect github.com/charmbracelet/x/cellbuf v0.0.15 // indirect github.com/charmbracelet/x/exp/strings v0.1.0 // indirect github.com/charmbracelet/x/term v0.2.2 // indirect diff --git a/go.sum b/go.sum index 6473982..9128b92 100644 --- a/go.sum +++ b/go.sum @@ -24,8 +24,6 @@ github.com/charmbracelet/huh v1.0.0 h1:wOnedH8G4qzJbmhftTqrpppyqHakl/zbbNdXIWJyI github.com/charmbracelet/huh v1.0.0/go.mod h1:5YVc+SlZ1IhQALxRPpkGwwEKftN/+OlJlnJYlDRFqN4= github.com/charmbracelet/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY= github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30= -github.com/charmbracelet/x/ansi v0.11.6 h1:GhV21SiDz/45W9AnV2R61xZMRri5NlLnl6CVF7ihZW8= -github.com/charmbracelet/x/ansi v0.11.6/go.mod h1:2JNYLgQUsyqaiLovhU2Rv/pb8r6ydXKS3NIttu3VGZQ= github.com/charmbracelet/x/ansi v0.11.7 h1:kzv1kJvjg2S3r9KHo8hDdHFQLEqn4RBCb39dAYC84jI= github.com/charmbracelet/x/ansi v0.11.7/go.mod h1:9qGpnAVYz+8ACONkZBUWPtL7lulP9No6p1epAihUZwQ= github.com/charmbracelet/x/cellbuf v0.0.15 h1:ur3pZy0o6z/R7EylET877CBxaiE1Sp1GMxoFPAIztPI= @@ -54,10 +52,6 @@ github.com/creack/pty v1.1.24 h1:bJrF4RRfyJnbTJqzRLHzcGaZK1NeM5kTC9jGgovnR1s= github.com/creack/pty v1.1.24/go.mod h1:08sCNb52WyoAwi2QDyzUCTgcvVFhUzewun7wtTfvcwE= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= -github.com/docker/cli v29.4.0+incompatible h1:+IjXULMetlvWJiuSI0Nbor36lcJ5BTcVpUmB21KBoVM= -github.com/docker/cli v29.4.0+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= -github.com/docker/cli v29.4.3+incompatible h1:u+UliYm2J/rYrIh2FqHQg32neRG8GjbvNuwQRTzGspU= -github.com/docker/cli v29.4.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/docker/cli v29.5.3+incompatible h1:nbEFfz774vBwQ5KRYv7c/AghjReqnGISvrRhzjV0evs= github.com/docker/cli v29.5.3+incompatible/go.mod h1:JLrzqnKDaYBop7H2jaqPtU4hHvMKP+vjCwu2uszcLI8= github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= @@ -66,8 +60,6 @@ github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6 github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= github.com/fatih/color v1.19.0 h1:Zp3PiM21/9Ld6FzSKyL5c/BULoe/ONr9KlbYVOfG8+w= github.com/fatih/color v1.19.0/go.mod h1:zNk67I0ZUT1bEGsSGyCZYZNrHuTkJJB+r6Q9VuMi0LE= -github.com/fsnotify/fsnotify v1.9.0 h1:2Ml+OJNzbYCTzsxtv8vKSFD9PbJjmhYF14k/jKC7S9k= -github.com/fsnotify/fsnotify v1.9.0/go.mod h1:8jBTzvmWwFyi3Pb8djgCCO5IBqzKJ/Jwo8TRcHyHii0= github.com/fsnotify/fsnotify v1.10.1 h1:b0/UzAf9yR5rhf3RPm9gf3ehBPpf0oZKIjtpKrx59Ho= github.com/fsnotify/fsnotify v1.10.1/go.mod h1:TLheqan6HD6GBK6PrDWyDPBaEV8LspOxvPSjC+bVfgo= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= @@ -82,18 +74,12 @@ github.com/kr/text v0.2.0 h1:5Nx0Ya0ZqY2ygV366QzturHI13Jq95ApcVaJBhpS+AY= github.com/kr/text v0.2.0/go.mod h1:eLer722TekiGuMkidMxC/pM04lWEeraHUUmBw8l2grE= github.com/lucasb-eyer/go-colorful v1.4.0 h1:UtrWVfLdarDgc44HcS7pYloGHJUjHV/4FwW4TvVgFr4= github.com/lucasb-eyer/go-colorful v1.4.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= -github.com/mattn/go-colorable v0.1.14 h1:9A9LHSqF/7dyVVX6g0U9cwm9pG3kP9gSzcuIPHPsaIE= -github.com/mattn/go-colorable v0.1.14/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= github.com/mattn/go-colorable v0.1.15 h1:+u9SLTRGnXv73cEsnsmoZBom+dMU88B2M0aDcWy0/jY= github.com/mattn/go-colorable v0.1.15/go.mod h1:6LmQG8QLFO4G5z1gPvYEzlUgJ2wF+stgPZH1UqBm1s8= -github.com/mattn/go-isatty v0.0.21 h1:xYae+lCNBP7QuW4PUnNG61ffM4hVIfm+zUzDuSzYLGs= -github.com/mattn/go-isatty v0.0.21/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-isatty v0.0.22 h1:j8l17JJ9i6VGPUFUYoTUKPSgKe/83EYU2zBC7YNKMw4= github.com/mattn/go-isatty v0.0.22/go.mod h1:ZXfXG4SQHsB/w3ZeOYbR0PrPwLy+n6xiMrJlRFqopa4= github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= -github.com/mattn/go-runewidth v0.0.23 h1:7ykA0T0jkPpzSvMS5i9uoNn2Xy3R383f9HDx3RybWcw= -github.com/mattn/go-runewidth v0.0.23/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mattn/go-runewidth v0.0.24 h1:cpokDiIn0MGnhdHwuWnJBITySJ20QyNGnY2kR/ay2DU= github.com/mattn/go-runewidth v0.0.24/go.mod h1:XBkDxAl56ILZc9knddidhrOlY5R/pDhgLpndooCuJAs= github.com/mikesmitty/edkey v0.0.0-20170222072505-3356ea4e686a h1:eU8j/ClY2Ty3qdHnn0TyW3ivFoPC/0F1gQZz8yTxbbE= @@ -131,10 +117,6 @@ github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no= github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= -golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= -golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/crypto v0.52.0 h1:RMs7fP2rXdep0CftQlK8Uf+kibLm7qkCcradZWYz988= golang.org/x/crypto v0.52.0/go.mod h1:1QgfPxDqh0T2M/elOJtp9RvuR95kVjir0e6/BvEmGbc= golang.org/x/exp v0.0.0-20231006140011-7918f672742d h1:jtJma62tbqLibJ5sFQz8bKtEM8rJBtfilJ2qTU199MI= @@ -143,18 +125,10 @@ golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.0.0-20210616094352-59db8d763f22/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= -golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/sys v0.45.0 h1:dO4czNzziLiiXplLQgBCEpCvXQ3dnkn0SdaZSYdQ+FY= golang.org/x/sys v0.45.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= -golang.org/x/term v0.42.0 h1:UiKe+zDFmJobeJ5ggPwOshJIVt6/Ft0rcfrXZDLWAWY= -golang.org/x/term v0.42.0/go.mod h1:Dq/D+snpsbazcBG5+F9Q1n2rXV8Ma+71xEjTRufARgY= golang.org/x/term v0.43.0 h1:S4RLU2sB31O/NCl+zFN9Aru9A/Cq2aqKpTZJ6B+DwT4= golang.org/x/term v0.43.0/go.mod h1:lrhlHNdQJHO+1qVYiHfFKVuVioJIheAc3fBSMFYEIsk= -golang.org/x/text v0.36.0 h1:JfKh3XmcRPqZPKevfXVpI1wXPTqbkE5f7JA92a55Yxg= -golang.org/x/text v0.36.0/go.mod h1:NIdBknypM8iqVmPiuco0Dh6P5Jcdk8lJL0CUebqK164= golang.org/x/text v0.37.0 h1:Cqjiwd9eSg8e0QAkyCaQTNHFIIzWtidPahFWR83rTrc= golang.org/x/text v0.37.0/go.mod h1:a5sjxXGs9hsn/AJVwuElvCAo9v8QYLzvavO5z2PiM38= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= diff --git a/main.go b/main.go index ad427cf..6c2d3cb 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,7 @@ import ( "fmt" "log/slog" "os" + "strconv" "github.com/moby/term" "github.com/spf13/cobra" @@ -23,6 +24,7 @@ import ( "github.com/iximiuz/labctl/cmd/portforward" "github.com/iximiuz/labctl/cmd/ssh" "github.com/iximiuz/labctl/cmd/sshproxy" + "github.com/iximiuz/labctl/cmd/tui" "github.com/iximiuz/labctl/cmd/tutorial" versioncmd "github.com/iximiuz/labctl/cmd/version" "github.com/iximiuz/labctl/internal/config" @@ -55,6 +57,14 @@ func main() { Use: "labctl ", Short: "labctl - iximiuz Labs command line interface.", Version: cli.Version(), + // Bare `labctl` launches the TUI when LABCTL_TUI is truthy, otherwise + // prints help as before. + RunE: func(cmd *cobra.Command, args []string) error { + if tuiDefaultEnabled() { + return labcli.WrapStatusError(tui.Run(cli)) + } + return cmd.Help() + }, PersistentPreRun: func(cmd *cobra.Command, args []string) { setLogLevel(cli, logLevel) cmd.SilenceUsage = true @@ -88,6 +98,7 @@ func main() { portforward.NewCommand(cli), ssh.NewCommand(cli), sshproxy.NewCommand(cli), + tui.NewCommand(cli), tutorial.NewCommand(cli), versioncmd.NewCommand(cli), ) @@ -121,6 +132,20 @@ func main() { } } +// tuiDefaultEnabled reports whether bare `labctl` should launch the TUI, based +// on the LABCTL_TUI env var (e.g. 1, true, yes). Unset or a falsy value (0, +// false) keeps the default help behavior. +func tuiDefaultEnabled() bool { + v, ok := os.LookupEnv("LABCTL_TUI") + if !ok { + return false + } + if b, err := strconv.ParseBool(v); err == nil { + return b + } + return v != "" // tolerate non-canonical truthy values like "yes"/"on" +} + func loadConfigOrFail(cli labcli.CLI, overrides configOverrides) { homeDir, err := os.UserHomeDir() if err != nil { From c4b58b63f7253485ee27756a5bef145e3765631e Mon Sep 17 00:00:00 2001 From: zAbuQasem Date: Mon, 15 Jun 2026 16:12:04 +0300 Subject: [PATCH 2/4] feat(tui): exports management, expose actions, and responsive layout - Expose: `w` shares a web terminal (private/public), `E` exposes HTTP port(s) (accepts a comma-separated list), `x` opens a combined Export dialog. Exposed URLs are copied to the clipboard. - Exports tab: a 4th tab aggregating every exposed shell/port across active labs; enter copies the URL, o opens it, ctrl+d unexposes. - Info popup lists a playground's currently exposed endpoints. - Ctrl+D now deletes (destroy on playgrounds, unexpose on exports) and no longer quits; x moved from destroy to Export. - Responsive layout: header degrades (drops logo, then menu) instead of overflowing, table columns shrink to fit, breathing-room rows collapse on short terminals, and a "terminal too small" message renders below the 60x14 floor. --- cmd/tui/tui.go | 690 ++++++++++++++++++++++++++++++++++++++------ cmd/tui/tui_test.go | 183 +++++++++++- go.mod | 2 +- 3 files changed, 783 insertions(+), 92 deletions(-) diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 75a25d6..b92b5b4 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -11,6 +11,7 @@ import ( "strings" "time" + "github.com/atotto/clipboard" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" @@ -55,9 +56,20 @@ const ( tabCatalog viewTab = iota tabPlays tabPersisted + tabExports ) -const tabCount = 3 +const tabCount = 4 + +// exportItem is one exposed shell or port across all labs (Exports tab). +type exportItem struct { + playID string + playName string + kind string // "shell" or the port number as text + url string + exposeID string // shell/port id, for unexpose + isPort bool +} type ( playsMsg struct{ plays, persisted []*api.Play } @@ -72,6 +84,12 @@ type ( loginDoneMsg struct{ err error } disarmQuitMsg struct{} statusClearMsg struct{ seq int } + exposedMsg struct{ kind, url string } // a shell/port was just exposed + exposedListMsg struct { // exposed endpoints for the Info popup + shells []*api.Shell + ports []*api.Port + } + exportsTabMsg struct{ items []exportItem } // aggregated exports for the Exports tab ) const statusFlashDuration = 4 * time.Second @@ -99,15 +117,18 @@ type pending struct { type modal uint8 const ( - modalNone modal = iota - modalFilter // footer search bar (renders within mainView) - modalExtend // lifetime dialog - modalAuth // sign-in popup - modalConfirm // destroy confirmation - modalQuit // quit confirmation - modalInfo // details popup - modalThemes // theme picker (live preview) - modalHelp // shortcuts popup + modalNone modal = iota + modalFilter // footer search bar (renders within mainView) + modalExtend // lifetime dialog + modalExport // export choice (web terminal / port) + modalShare // share-terminal access choice (private/public) + modalExposePort // expose-port number input + modalAuth // sign-in popup + modalConfirm // destroy confirmation + modalQuit // quit confirmation + modalInfo // details popup + modalThemes // theme picker (live preview) + modalHelp // shortcuts popup ) type model struct { @@ -117,21 +138,30 @@ type model struct { playsTable table.Model persistedTable table.Model catalogTable table.Model + exportsTable table.Model plays []*api.Play // full, unfiltered persisted []*api.Play // persistent plays (ListPlays{Persistent:true}) catalog []api.Playground // full, unfiltered + exports []exportItem // aggregated exposed shells/ports filteredPlays []*api.Play // rows currently shown (cursor indexes this) filteredPers []*api.Play filteredCat []api.Playground + filteredExp []exportItem modal modal // the single active overlay/prompt filter string - input textinput.Model // filter + extend lifetime field + input textinput.Model // filter + extend lifetime + expose-port field extendBtn int // 0 = field, 1 = Cancel, 2 = OK extendID string // play being extended + exposeID string // play being shared / port-exposed + shareBtn int // 0 = Private, 1 = Public + exportBtn int // 0 = Web terminal, 1 = Port + infoShells []*api.Shell // exposed shells (loaded for the Info popup) + infoPorts []*api.Port // exposed ports (loaded for the Info popup) + status string statusSeq int // bumped per flash; stale auto-clears are ignored confirm pending // destroy target (valid while modal == modalConfirm) @@ -152,12 +182,14 @@ func newModel(cli labcli.CLI) model { pt := table.New(table.WithFocused(true)) pp := table.New() ct := table.New() + ex := table.New() applySkin(&pt) applySkin(&pp) applySkin(&ct) + applySkin(&ex) ti := textinput.New() ti.Prompt = "" - m := model{cli: cli, tab: tabPlays, playsTable: pt, persistedTable: pp, catalogTable: ct, input: ti, status: "Loading...", theme: "k9s"} + m := model{cli: cli, tab: tabPlays, playsTable: pt, persistedTable: pp, catalogTable: ct, exportsTable: ex, input: ti, status: "Loading...", theme: "k9s"} m.setSizes(80, 24) return m } @@ -269,6 +301,135 @@ func (m model) extendPlay(id string, minutes int) tea.Cmd { }) } +func access(public bool) api.AccessMode { + if public { + return api.AccessPublic + } + return api.AccessPrivate +} + +// shareTerminal exposes a web terminal for the playground and returns its URL. +func (m model) shareTerminal(id string, public bool) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + p, err := m.cli.Client().GetPlay(ctx, id) + if err != nil { + return errMsg{err} + } + machine, err := p.ResolveMachine("") + if err != nil { + return errMsg{err} + } + user, err := p.ResolveUser(machine, "") + if err != nil { + return errMsg{err} + } + sh, err := m.cli.Client().ExposeShell(ctx, id, api.ExposeShellRequest{ + Machine: machine, User: user, Access: access(public), + }) + if err != nil { + return errMsg{err} + } + return exposedMsg{kind: "Terminal", url: sh.URL} + } +} + +// exposePort exposes an HTTP service running in the playground and returns its +// public URL. +// exposePort exposes one or more HTTP ports (the API takes one per call, so we +// fan out) and returns the joined URLs. +func (m model) exposePort(id string, ports []int) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second) + defer cancel() + p, err := m.cli.Client().GetPlay(ctx, id) + if err != nil { + return errMsg{err} + } + machine, err := p.ResolveMachine("") + if err != nil { + return errMsg{err} + } + var urls []string + for _, port := range ports { + pt, err := m.cli.Client().ExposePort(ctx, id, api.ExposePortRequest{ + Machine: machine, Number: port, Access: api.AccessPublic, + }) + if err != nil { + return errMsg{err} + } + urls = append(urls, pt.URL) + } + kind := "Port" + if len(urls) > 1 { + kind = fmt.Sprintf("%d ports", len(urls)) + } + return exposedMsg{kind: kind, url: strings.Join(urls, " ")} + } +} + +// loadExposed fetches the currently exposed shells/ports for the Info popup. +func (m model) loadExposed(id string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + shells, _ := m.cli.Client().ListShells(ctx, id) + ports, _ := m.cli.Client().ListPorts(ctx, id) + return exposedListMsg{shells: shells, ports: ports} + } +} + +// loadExports aggregates exposed shells/ports across all active labs for the +// Exports tab. +func (m model) loadExports() tea.Cmd { + plays := append(append([]*api.Play{}, m.plays...), m.persisted...) + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + var items []exportItem + for _, p := range plays { + if !p.IsActive() { + continue + } + for _, sh := range mustShells(m.cli.Client().ListShells(ctx, p.ID)) { + items = append(items, exportItem{ + playID: p.ID, playName: p.Playground.Name, + kind: "shell", url: sh.URL, exposeID: sh.ID, + }) + } + for _, pt := range mustPorts(m.cli.Client().ListPorts(ctx, p.ID)) { + items = append(items, exportItem{ + playID: p.ID, playName: p.Playground.Name, + kind: strconv.Itoa(pt.Number), url: pt.URL, exposeID: pt.ID, isPort: true, + }) + } + } + return exportsTabMsg{items: items} + } +} + +func mustShells(s []*api.Shell, _ error) []*api.Shell { return s } +func mustPorts(p []*api.Port, _ error) []*api.Port { return p } + +// unexpose removes one exposed shell or port. +func (m model) unexpose(e exportItem) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + var err error + if e.isPort { + err = m.cli.Client().UnexposePort(ctx, e.playID, e.exposeID) + } else { + err = m.cli.Client().UnexposeShell(ctx, e.playID, e.exposeID) + } + if err != nil { + return errMsg{err} + } + return actionMsg{"Unexposed " + e.kind} + } +} + func (m model) startPlay(name string) tea.Cmd { return m.playAction("Started "+name, func(ctx context.Context) error { // ponytail: official catalog playgrounds need no safety consent; auto-ack. @@ -353,6 +514,25 @@ func (m *model) refreshRows() { } } m.catalogTable.SetRows(cr) + + m.filteredExp = m.filteredExp[:0] + er := make([]table.Row, 0, len(m.exports)) + for _, e := range m.exports { + row := table.Row{e.playName, e.kind, e.url} + if f == "" || strings.Contains(strings.ToLower(strings.Join(row, " ")), f) { + m.filteredExp = append(m.filteredExp, e) + er = append(er, row) + } + } + m.exportsTable.SetRows(er) +} + +func (m *model) selectedExport() *exportItem { + i := m.exportsTable.Cursor() + if i < 0 || i >= len(m.filteredExp) { + return nil + } + return &m.filteredExp[i] } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { @@ -444,13 +624,33 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { return m, tea.Batch(m.loadPlays(), tick()) case actionMsg: - clear := m.flash(okMark + " " + msg.info) - return m, tea.Batch(m.loadPlays(), clear) + cmds := []tea.Cmd{m.loadPlays(), m.flash(okMark + " " + msg.info)} + if m.tab == tabExports { // keep the exports list fresh after unexpose etc. + cmds = append(cmds, m.loadExports()) + } + return m, tea.Batch(cmds...) case errMsg: clear := m.flash(errMark + " " + msg.err.Error()) return m, clear + case exposedMsg: + note := okMark + " " + msg.kind + " exposed: " + msg.url + if err := clipboard.WriteAll(msg.url); err == nil { + note += " (copied)" + } + return m, m.flash(note) + + case exposedListMsg: + m.infoShells = msg.shells + m.infoPorts = msg.ports + return m, nil + + case exportsTabMsg: + m.exports = msg.items + m.refreshRows() + return m, nil + case tea.KeyMsg: return m.handleKey(msg) } @@ -484,9 +684,9 @@ func renderStatus(s string) string { } func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - // Double ctrl+c / ctrl+d to quit (Claude-CLI style): first press arms and - // hints, a second within the window quits. Works in every mode. - if s := msg.String(); s == "ctrl+c" || s == "ctrl+d" { + // Double ctrl+c to quit (Claude-CLI style): first press arms and hints, a + // second within the window quits. (ctrl+d is the delete key, see below.) + if msg.String() == "ctrl+c" { if m.quitArmed { return m, tea.Quit } @@ -502,6 +702,12 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleThemesKey(msg) case modalExtend: return m.handleExtendSelectKey(msg) + case modalExport: + return m.handleExportKey(msg) + case modalShare: + return m.handleShareKey(msg) + case modalExposePort: + return m.handleExposePortKey(msg) case modalFilter: return m.handlePromptKey(msg) case modalQuit: @@ -532,15 +738,19 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "tab", "right", "l": m.switchTab(1) - return m, nil + return m, m.tabEnterCmd() case "shift+tab", "left", "h": m.switchTab(-1) - return m, nil + return m, m.tabEnterCmd() case "r": m.status = "Refreshing..." - return m, tea.Batch(m.loadPlays(), m.loadCatalog()) + cmds := []tea.Cmd{m.loadPlays(), m.loadCatalog()} + if m.tab == tabExports { + cmds = append(cmds, m.loadExports()) + } + return m, tea.Batch(cmds...) case "?": // shortcuts popup m.modal = modalHelp @@ -552,17 +762,28 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "i": // show full details of the selected row - if (m.tab == tabCatalog && m.selectedPlayground() != nil) || - (m.tab != tabCatalog && m.selectedPlay() != nil) { + m.infoShells, m.infoPorts = nil, nil + if m.tab == tabCatalog { + if m.selectedPlayground() != nil { + m.modal = modalInfo + } + return m, nil + } + if p := m.selectedPlay(); p != nil { m.modal = modalInfo + return m, m.loadExposed(p.ID) // populate the exposed-endpoints section } return m, nil } - if m.tab == tabCatalog { + switch m.tab { + case tabCatalog: return m.handleCatalogKey(msg) + case tabExports: + return m.handleExportsKey(msg) + default: + return m.handlePlaysKey(msg) // tabPlays + tabPersisted share actions } - return m.handlePlaysKey(msg) // tabPlays + tabPersisted share actions } // switchTab moves to the next/previous tab and focuses its table. @@ -571,15 +792,27 @@ func (m *model) switchTab(delta int) { m.focusActiveTable() } +// tabEnterCmd loads data needed by the freshly-entered tab (exports are fetched +// lazily, not on the 5s poll). +func (m model) tabEnterCmd() tea.Cmd { + if m.tab == tabExports { + return m.loadExports() + } + return nil +} + func (m *model) focusActiveTable() { m.playsTable.Blur() m.persistedTable.Blur() m.catalogTable.Blur() + m.exportsTable.Blur() switch m.tab { case tabPersisted: m.persistedTable.Focus() case tabCatalog: m.catalogTable.Focus() + case tabExports: + m.exportsTable.Focus() default: m.playsTable.Focus() } @@ -689,6 +922,87 @@ func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// handleShareKey drives the share-terminal access choice (Private/Public). +// handleExportKey drives the Export choice (Web terminal / Port). +func (m model) handleExportKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "down", "left", "right", "tab", "j", "k", "h", "l": + m.exportBtn = 1 - m.exportBtn + return m, nil + case "esc": + m.modal = modalNone + return m, nil + case "enter": + if m.exportBtn == 0 { // Web terminal -> access choice + m.modal = modalShare + m.shareBtn = 0 + return m, nil + } + m.modal = modalExposePort // Port -> number input + m.input.SetValue("") + m.input.Placeholder = "ports e.g. 8080, 9090" + return m, m.input.Focus() + default: + return m, nil + } +} + +func (m model) handleShareKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "down", "left", "right", "tab", "j", "k", "h", "l": + m.shareBtn = 1 - m.shareBtn + return m, nil + case "esc": + m.modal = modalNone + return m, nil + case "enter": + m.modal = modalNone + m.status = "Sharing terminal..." + return m, m.shareTerminal(m.exposeID, m.shareBtn == 1) + default: + return m, nil + } +} + +// parsePorts parses a comma/space-separated list of valid ports. +func parsePorts(s string) ([]int, bool) { + fields := strings.FieldsFunc(s, func(r rune) bool { return r == ',' || r == ' ' }) + ports := make([]int, 0, len(fields)) + for _, f := range fields { + n, err := strconv.Atoi(f) + if err != nil || n < 1 || n > 65535 { + return nil, false + } + ports = append(ports, n) + } + return ports, len(ports) > 0 +} + +// handleExposePortKey drives the expose-port input (accepts a list). +func (m model) handleExposePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.modal = modalNone + m.input.Blur() + return m, nil + case "enter": + ports, ok := parsePorts(m.input.Value()) + id := m.exposeID + m.modal = modalNone + m.input.Blur() + if !ok { + m.status = errMark + " Invalid port(s): " + m.input.Value() + return m, nil + } + m.status = "Exposing port(s)..." + return m, m.exposePort(id, ports) + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } +} + // handlePromptKey drives the filter input (live-filters as you type). func (m model) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { @@ -773,7 +1087,35 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.input.SetValue("") m.input.Placeholder = "90m, 3h" return m, m.input.Focus() - case "x": + case "w": // share a web terminal (choose access) + if p == nil || !p.IsActive() { + m.status = errMark + " Select a running playground to share" + return m, nil + } + m.modal = modalShare + m.exposeID = p.ID + m.shareBtn = 0 // default to Private + return m, nil + case "E": // expose an HTTP port + if p == nil || !p.IsActive() { + m.status = errMark + " Select a running playground to expose a port" + return m, nil + } + m.modal = modalExposePort + m.exposeID = p.ID + m.input.SetValue("") + m.input.Placeholder = "port e.g. 8080" + return m, m.input.Focus() + case "x": // open the Export dialog (web terminal / port) + if p == nil || !p.IsActive() { + m.status = errMark + " Select a running playground to export" + return m, nil + } + m.modal = modalExport + m.exposeID = p.ID + m.exportBtn = 0 + return m, nil + case "ctrl+d": // destroy the playground (with confirmation) if p == nil { return m, nil } @@ -787,6 +1129,36 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, cmd } +// handleExportsKey drives the Exports tab: enter copies the URL, o opens it, +// ctrl+d unexposes the selected endpoint. +func (m model) handleExportsKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + e := m.selectedExport() + switch msg.String() { + case "enter": + if e == nil { + return m, nil + } + note := okMark + " " + e.url + if err := clipboard.WriteAll(e.url); err == nil { + note += " (copied)" + } + return m, m.flash(note) + case "o": + if e != nil { + _ = browser.Open(e.url) + } + return m, nil + case "ctrl+d": // unexpose the selected endpoint + if e == nil { + return m, nil + } + m.status = "Unexposing..." + return m, m.unexpose(*e) + } + cmd := m.delegate(msg) + return m, cmd +} + func (m model) handleCatalogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if msg.String() == "enter" { pg := m.selectedPlayground() @@ -811,16 +1183,53 @@ func (m *model) delegate(msg tea.Msg) tea.Cmd { m.persistedTable, cmd = m.persistedTable.Update(msg) case tabCatalog: m.catalogTable, cmd = m.catalogTable.Update(msg) + case tabExports: + m.exportsTable, cmd = m.exportsTable.Update(msg) default: m.playsTable, cmd = m.playsTable.Update(msg) } return cmd } +// gapsFor returns the blank-row breathing space (header→table, title→header), +// collapsed to 0 on short terminals to reclaim data rows. +func gapsFor(h int) (header, table int) { + if h < 22 { + return 0, 0 + } + return 1, 1 +} + +// distribute splits total across columns by weight, honoring per-column minimums +// so columns shrink gracefully on narrow terminals instead of overflowing. +func distribute(total int, weights, mins []int) []int { + out := make([]int, len(weights)) + sumMin, sumW := 0, 0 + for i := range weights { + sumMin += mins[i] + sumW += weights[i] + } + extra := total - sumMin + if extra < 0 { + extra = 0 + } + used := 0 + for i := range weights { + out[i] = mins[i] + if sumW > 0 { + add := extra * weights[i] / sumW + out[i] += add + used += add + } + } + out[len(out)-1] += extra - used // rounding remainder to the last column + return out +} + func (m *model) setSizes(w, h int) { - // Reserve rows: header(4) + gap(2) + box border(2) + footer(1) + table top - // gap(1). Fixed so the table is the same height on both tabs. - bodyH := h - headerRows - headerGap - 3 - tableGap + hGap, tGap := gapsFor(h) + // Reserve rows: header(4) + header gap + box border(2) + footer(1) + table gap. + bodyH := h - headerRows - hGap - 3 - tGap if bodyH < 3 { bodyH = 3 } @@ -829,32 +1238,34 @@ func (m *model) setSizes(w, h int) { m.catalogTable.SetHeight(bodyH) inner := w - 2 // titledBox eats one column per side - idW, nameW, statusW := 26, 18, 26 - ageW := inner - idW - nameW - statusW - 6 - if ageW < 10 { - ageW = 10 - } + + pc := distribute(inner-6, []int{3, 2, 3, 2}, []int{10, 8, 8, 6}) // ID NAME STATUS AGE playCols := []table.Column{ - {Title: "ID", Width: idW}, - {Title: "NAME", Width: nameW}, - {Title: "STATUS", Width: statusW}, - {Title: "AGE", Width: ageW}, + {Title: "ID", Width: pc[0]}, + {Title: "NAME", Width: pc[1]}, + {Title: "STATUS", Width: pc[2]}, + {Title: "AGE", Width: pc[3]}, } m.playsTable.SetColumns(playCols) m.playsTable.SetWidth(inner) m.persistedTable.SetColumns(playCols) m.persistedTable.SetWidth(inner) - cNameW := 22 - descW := inner - cNameW - 4 - if descW < 20 { - descW = 20 - } + cc := distribute(inner-4, []int{1, 3}, []int{12, 15}) // PLAYGROUND DESCRIPTION m.catalogTable.SetColumns([]table.Column{ - {Title: "PLAYGROUND", Width: cNameW}, - {Title: "DESCRIPTION", Width: descW}, + {Title: "PLAYGROUND", Width: cc[0]}, + {Title: "DESCRIPTION", Width: cc[1]}, }) m.catalogTable.SetWidth(inner) + + ec := distribute(inner-6, []int{2, 1, 4}, []int{8, 6, 16}) // LAB KIND URL + m.exportsTable.SetColumns([]table.Column{ + {Title: "LAB", Width: ec[0]}, + {Title: "KIND", Width: ec[1]}, + {Title: "URL", Width: ec[2]}, + }) + m.exportsTable.SetWidth(inner) + m.exportsTable.SetHeight(bodyH) } func shortStatus(p *api.Play) string { @@ -1128,6 +1539,13 @@ func (m model) overlayCentered(box string) string { return overlay(m.mainView(), box, x, y) } +func (m model) tooSmallView() string { + w, h := m.dims() + msg := lipgloss.NewStyle().Foreground(cLogo).Bold(true).Render("Terminal too small") + sub := helpStyle.Render(fmt.Sprintf("needs at least %dx%d (now %dx%d)", minWidth, minHeight, w, h)) + return lipgloss.Place(w, h, lipgloss.Center, lipgloss.Center, msg+"\n"+sub) +} + // dims returns the window size with an 80x24 fallback before the first resize. func (m model) dims() (int, int) { if m.width == 0 { @@ -1137,6 +1555,9 @@ func (m model) dims() (int, int) { } func (m model) View() string { + if w, h := m.dims(); w < minWidth || h < minHeight { + return m.tooSmallView() + } switch m.modal { case modalAuth: return m.authView() @@ -1146,6 +1567,12 @@ func (m model) View() string { return m.quitView() case modalExtend: return m.extendSelectView() + case modalExport: + return m.exportView() + case modalShare: + return m.shareView() + case modalExposePort: + return m.exposePortView() case modalInfo: return m.infoView() case modalThemes: @@ -1166,6 +1593,8 @@ func (m model) mainView() string { body = m.persistedTable.View() case tabCatalog: body = m.catalogTable.View() + case tabExports: + body = m.exportsTable.View() default: body = m.playsTable.View() } @@ -1180,14 +1609,16 @@ func (m model) mainView() string { footer = renderStatus(m.status) } - body = strings.Repeat("\n", tableGap) + body // gap between tab title and header row + _, h := m.dims() + hGap, tGap := gapsFor(h) + body = strings.Repeat("\n", tGap) + body // gap between tab title and header row - return strings.Join([]string{ - m.headerView(w), - "", // headerGap blank row between header and table - titledBox(m.tabsTitle(), body, w-2), - footer, - }, "\n") + rows := []string{m.headerView(w)} + for range hGap { + rows = append(rows, "") + } + rows = append(rows, titledBox(m.tabsTitle(), body, w-2), footer) + return strings.Join(rows, "\n") } // tabsTitle renders the playgrounds/catalog tabs as filled blocks embedded in @@ -1208,6 +1639,8 @@ func (m model) tabsTitle() string { count = len(m.filteredPers) case tabCatalog: count = len(m.filteredCat) + case tabExports: + count = len(m.filteredExp) default: count = len(m.filteredPlays) } @@ -1217,7 +1650,8 @@ func (m model) tabsTitle() string { } return tab("catalog", m.tab == tabCatalog) + " " + tab("playgrounds", m.tab == tabPlays) + " " + - tab("persisted", m.tab == tabPersisted) + dialogTitle.Render(suffix) + tab("persisted", m.tab == tabPersisted) + " " + + tab("exports", m.tab == tabExports) + dialogTitle.Render(suffix) } // applyTheme switches the active color scheme immediately (used for the live @@ -1228,6 +1662,7 @@ func (m *model) applyTheme(name string) { applySkin(&m.playsTable) applySkin(&m.persistedTable) applySkin(&m.catalogTable) + applySkin(&m.exportsTable) } // overlay composites box onto bg at column x, row y (ANSI-aware), leaving the @@ -1370,6 +1805,30 @@ func (m model) quitView() string { })) } +func (m model) exportView() string { + return m.overlayCentered(kDialog("Export", []string{ + menuText.Render("What to expose?"), + "", + buttonRow([]string{"Web terminal", "Port"}, m.exportBtn), + })) +} + +func (m model) shareView() string { + return m.overlayCentered(kDialog("Share terminal", []string{ + menuText.Render("Web terminal access:"), + "", + buttonRow([]string{"Private", "Public"}, m.shareBtn), + })) +} + +func (m model) exposePortView() string { + return m.overlayCentered(kDialog("Expose port(s)", []string{ + menuText.Render("HTTP port(s) to expose publicly"), + "", + formField("Port(s):", m.input, true), + })) +} + func (m *model) selectedURL() string { if m.tab == tabCatalog { if pg := m.selectedPlayground(); pg != nil { @@ -1429,6 +1888,10 @@ func (m model) infoView() string { } parts := []string{strings.Join(fields, "\n")} + if endpoints := m.exposedLines(contentW); len(endpoints) > 0 { + parts = append(parts, "", logoStyle.Render("Exposed")) + parts = append(parts, endpoints...) + } if desc != "" { wrapped := lipgloss.NewStyle().Width(contentW).Foreground(cBody).Render(desc) parts = append(parts, "", wrapped) @@ -1438,6 +1901,19 @@ func (m model) infoView() string { return m.centered(titledBox(dialogTitle.Render(title), strings.Join(parts, "\n"), contentW+2)) } +// exposedLines formats the loaded exposed shells/ports for the Info popup. +func (m model) exposedLines(w int) []string { + var out []string + trunc := func(s string) string { return ansi.Truncate(s, w, "…") } + for _, sh := range m.infoShells { + out = append(out, trunc(infoVal.Render("shell ")+menuText.Render(sh.URL))) + } + for _, pt := range m.infoPorts { + out = append(out, trunc(infoVal.Render(fmt.Sprintf("%-7d", pt.Number))+menuText.Render(pt.URL))) + } + return out +} + func (m model) headerView(w int) string { user := m.user if user == "" { @@ -1449,13 +1925,18 @@ func (m model) headerView(w int) string { infoLine("Plays", strconv.Itoa(len(m.plays))), infoLine("Catalog", strconv.Itoa(len(m.catalog))), ) - logo := logoStyle.Render(strings.Join(logoSmall, "\n")) - left := lipgloss.JoinHorizontal(lipgloss.Top, info, " ", m.menuView()) - gap := w - lipgloss.Width(left) - lipgloss.Width(logo) - if gap < 1 { - gap = 1 + + // Greedily add the menu, then the logo, only when each still fits the width — + // so the header degrades instead of overflowing on narrow terminals. + header := info + if menu := m.menuView(); lipgloss.Width(header)+5+lipgloss.Width(menu) <= w { + header = lipgloss.JoinHorizontal(lipgloss.Top, header, " ", menu) } - header := lipgloss.JoinHorizontal(lipgloss.Top, left, strings.Repeat(" ", gap), logo) + if logo := logoStyle.Render(strings.Join(logoSmall, "\n")); lipgloss.Width(header)+2+lipgloss.Width(logo) <= w { + gap := w - lipgloss.Width(header) - lipgloss.Width(logo) + header = lipgloss.JoinHorizontal(lipgloss.Top, header, strings.Repeat(" ", gap), logo) + } + // Pin the header to exactly headerRows so the table never shifts between tabs. lines := strings.Split(header, "\n") for len(lines) < headerRows { @@ -1464,41 +1945,64 @@ func (m model) headerView(w int) string { return strings.Join(lines[:headerRows], "\n") } +const headerRows = 4 // header pinned to this many rows for cross-tab stability + +// Below this the layout can't render usefully; show a "too small" message. const ( - headerRows = 4 - headerGap = 1 // blank row between header and table - tableGap = 1 // blank row between the tab title and the table header + minWidth = 60 + minHeight = 14 ) -// menuView shows only the essential shortcuts (everything else lives in the ? -// popup). Both tabs use the same item count so the header height is stable. +// menuView shows the per-tab essential shortcuts (everything else lives in the +// ? popup). At most 3 rows tall, so the 4-row header stays stable. func (m model) menuView() string { - enter := "SSH" - if m.tab == tabCatalog { - enter = "Start" - } - items := [][2]string{ - {"enter", enter}, {"i", "Info"}, {":", "Filter"}, - {"tab", "Switch"}, {"r", "Refresh"}, {"?", "Shortcuts"}, - } - if m.tab == tabPlays { // persist only applies to (non-persistent) playgrounds - items = append(items, [2]string{"P", "Persist"}) + var items [][2]string + switch m.tab { + case tabCatalog: + items = [][2]string{ + {"enter", "Start"}, {"i", "Info"}, {":", "Filter"}, + {"r", "Refresh"}, {"tab", "Switch"}, {"?", "Shortcuts"}, + } + case tabExports: + items = [][2]string{ + {"enter", "Copy"}, {"o", "Open"}, {"ctrl+d", "Unexpose"}, + {":", "Filter"}, {"r", "Refresh"}, {"?", "Shortcuts"}, + } + case tabPersisted: + items = [][2]string{ + {"enter", "SSH"}, {"i", "Info"}, {"w", "Share"}, + {"E", "Expose"}, {"x", "Export"}, {":", "Filter"}, + {"r", "Refresh"}, {"tab", "Switch"}, {"?", "Shortcuts"}, + } + default: // tabPlays + items = [][2]string{ + {"enter", "SSH"}, {"i", "Info"}, {"w", "Share"}, + {"E", "Expose"}, {"x", "Export"}, {"P", "Persist"}, + {":", "Filter"}, {"r", "Refresh"}, {"?", "Shortcuts"}, + } } - half := (len(items) + 1) / 2 - var col1, col2 []string - for i, it := range items { - line := menuKey.Render("<"+it[0]+"> ") + menuText.Render(it[1]) - if i < half { - col1 = append(col1, line) - } else { - col2 = append(col2, line) + return menuColumns(items) +} + +// menuColumns lays items out column-major in 3-row columns. +func menuColumns(items [][2]string) string { + const rows = 3 + cols := (len(items) + rows - 1) / rows + parts := make([]string, 0, cols*2) + for c := range cols { + var lines []string + for r := range rows { + if idx := c*rows + r; idx < len(items) { + it := items[idx] + lines = append(lines, menuKey.Render("<"+it[0]+"> ")+menuText.Render(it[1])) + } + } + if c > 0 { + parts = append(parts, " ") } + parts = append(parts, lipgloss.JoinVertical(lipgloss.Left, lines...)) } - return lipgloss.JoinHorizontal(lipgloss.Top, - lipgloss.JoinVertical(lipgloss.Left, col1...), - " ", - lipgloss.JoinVertical(lipgloss.Left, col2...), - ) + return lipgloss.JoinHorizontal(lipgloss.Top, parts...) } func (m model) helpView() string { @@ -1522,12 +2026,18 @@ func (m model) helpView() string { key("t", "restart"), key("P", "persist"), key("e", "extend lifetime"), - key("x", "destroy"), + key("ctrl+d", "destroy"), }, "\n") right := strings.Join([]string{ - hdr("Catalog"), - key("enter", "start"), - key("i", "info"), + hdr("Export"), + key("w", "share terminal"), + key("E", "expose port(s)"), + key("x", "export dialog"), + "", + hdr("Exports tab"), + key("enter", "copy url"), + key("o", "open url"), + key("ctrl+d", "unexpose"), "", hdr("General"), key("r", "refresh"), diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 81921ac..0897c72 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -8,6 +8,7 @@ import ( "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" "github.com/iximiuz/labctl/api" "github.com/iximiuz/labctl/internal/config" @@ -262,6 +263,186 @@ func TestPersistAction(t *testing.T) { } } +// TestTooSmallFloor guards the minimum-size message below the layout floor. +func TestTooSmallFloor(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.width, m.height = 50, 10 + if out := m.View(); !strings.Contains(out, "too small") { + t.Fatalf("View at 50x10 should show the too-small message, got:\n%s", out) + } +} + +// TestNoOverflowAcrossWidths guards that no rendered line exceeds the terminal +// width (the header degrades and columns shrink instead of overflowing). +func TestNoOverflowAcrossWidths(t *testing.T) { + t.Parallel() + for _, wh := range [][2]int{{60, 14}, {62, 16}, {80, 24}, {120, 40}} { + m := newModel(testCLI()) + m.width, m.height = wh[0], wh[1] + m.setSizes(wh[0], wh[1]) + m.plays = []*api.Play{playWithState("abcd1234ef56", api.StateRunning)} + m.refreshRows() + for _, line := range strings.Split(m.View(), "\n") { + if got := lipgloss.Width(line); got > wh[0] { + t.Fatalf("%dx%d: line width %d > %d: %q", wh[0], wh[1], got, wh[0], line) + } + } + } +} + +func TestDistribute(t *testing.T) { + t.Parallel() + mins := []int{10, 8, 8, 6} + got := distribute(54, []int{3, 2, 3, 2}, mins) + sum := 0 + for i, v := range got { + if v < mins[i] { + t.Fatalf("col %d = %d below min %d", i, v, mins[i]) + } + sum += v + } + if sum != 54 { + t.Fatalf("sum = %d, want 54", sum) + } +} + +// TestShareTerminalOpens guards that `w` opens the share-access dialog defaulting +// to Private. +func TestShareTerminalOpens(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("p1", api.StateRunning)} + m.refreshRows() + + out, _ := m.handlePlaysKey(runeKey("w")) + mo := out.(model) + if mo.modal != modalShare { + t.Fatalf("modal = %v, want modalShare", mo.modal) + } + if mo.shareBtn != 0 { + t.Fatalf("shareBtn = %d, want 0 (Private)", mo.shareBtn) + } + if mo.exposeID != "p1" { + t.Fatalf("exposeID = %q, want p1", mo.exposeID) + } +} + +// TestExportDialogRoutes guards that x opens the Export dialog and routes to the +// share / expose-port sub-flows. +func TestExportDialogRoutes(t *testing.T) { + t.Parallel() + open := func() model { + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("p1", api.StateRunning)} + m.refreshRows() + out, _ := m.handlePlaysKey(runeKey("x")) + return out.(model) + } + if m := open(); m.modal != modalExport { + t.Fatalf("x: modal = %v, want modalExport", m.modal) + } + // Web terminal (button 0) -> share access choice. + web, _ := open().handleExportKey(tea.KeyMsg{Type: tea.KeyEnter}) + if web.(model).modal != modalShare { + t.Fatalf("Export>Web: modal = %v, want modalShare", web.(model).modal) + } + // Port (button 1) -> port input. + m := open() + m.exportBtn = 1 + port, _ := m.handleExportKey(tea.KeyMsg{Type: tea.KeyEnter}) + if port.(model).modal != modalExposePort { + t.Fatalf("Export>Port: modal = %v, want modalExposePort", port.(model).modal) + } +} + +// TestCtrlDDestroys guards that Ctrl+D opens the destroy confirmation on the +// Playgrounds tab. +func TestCtrlDDestroys(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("p1", api.StateRunning)} + m.refreshRows() + + out, _ := m.handlePlaysKey(tea.KeyMsg{Type: tea.KeyCtrlD}) + if mo := out.(model); mo.modal != modalConfirm || mo.confirm.id != "p1" { + t.Fatalf("ctrl+d: modal=%v confirm=%q, want modalConfirm/p1", mo.modal, mo.confirm.id) + } +} + +// TestUnexposeOnExportsTab guards that Ctrl+D unexposes the selected export. +func TestUnexposeOnExportsTab(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.tab = tabExports + m.exports = []exportItem{{playID: "p1", playName: "pg", kind: "shell", url: "https://x", exposeID: "s1"}} + m.refreshRows() + + out, cmd := m.handleExportsKey(tea.KeyMsg{Type: tea.KeyCtrlD}) + if got := out.(model).status; got != "Unexposing..." { + t.Fatalf("status = %q, want Unexposing...", got) + } + if cmd == nil { + t.Fatal("unexpose should return a command") + } +} + +func TestParsePorts(t *testing.T) { + t.Parallel() + tests := []struct { + in string + want []int + ok bool + }{ + {in: "8080", want: []int{8080}, ok: true}, + {in: "8080, 9090", want: []int{8080, 9090}, ok: true}, + {in: "80 443 8080", want: []int{80, 443, 8080}, ok: true}, + {in: "abc", ok: false}, + {in: "70000", ok: false}, + {in: "", ok: false}, + } + for _, tt := range tests { + got, ok := parsePorts(tt.in) + if ok != tt.ok { + t.Fatalf("parsePorts(%q) ok = %v, want %v", tt.in, ok, tt.ok) + } + if ok && len(got) != len(tt.want) { + t.Fatalf("parsePorts(%q) = %v, want %v", tt.in, got, tt.want) + } + } +} + +// TestExposePortValidation guards the port parsing in the expose-port dialog. +func TestExposePortValidation(t *testing.T) { + t.Parallel() + tests := []struct { + name string + input string + wantPrefix string + }{ + {name: "valid", input: "8080", wantPrefix: "Exposing port(s)..."}, + {name: "multiple", input: "8080, 9090", wantPrefix: "Exposing port(s)..."}, + {name: "not a number", input: "abc", wantPrefix: errMark}, + {name: "zero", input: "0", wantPrefix: errMark}, + {name: "too large", input: "70000", wantPrefix: errMark}, + {name: "empty", input: "", wantPrefix: errMark}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.modal = modalExposePort + m.exposeID = "p1" + m.input.SetValue(tt.input) + + out, _ := m.handleExposePortKey(tea.KeyMsg{Type: tea.KeyEnter}) + if got := out.(model).status; !strings.HasPrefix(got, tt.wantPrefix) { + t.Fatalf("input %q: status = %q, want prefix %q", tt.input, got, tt.wantPrefix) + } + }) + } +} + // TestPersistOnlyOnPlaygrounds guards that P is a no-op on the Persisted tab // (persisted labs are already persistent). func TestPersistOnlyOnPlaygrounds(t *testing.T) { @@ -298,7 +479,7 @@ func TestPersistedTabSelection(t *testing.T) { func TestSwitchTabCycles(t *testing.T) { t.Parallel() m := newModel(testCLI()) - for _, want := range []viewTab{tabPersisted, tabCatalog, tabPlays} { + for _, want := range []viewTab{tabPersisted, tabExports, tabCatalog, tabPlays} { m.switchTab(1) if m.tab != want { t.Fatalf("after +1, tab = %v, want %v", m.tab, want) diff --git a/go.mod b/go.mod index f428803..323c138 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/iximiuz/labctl go 1.25.0 require ( + github.com/atotto/clipboard v0.1.4 github.com/briandowns/spinner v1.23.2 github.com/cenkalti/backoff/v5 v5.0.3 github.com/charmbracelet/bubbles v1.0.0 @@ -29,7 +30,6 @@ require ( require ( github.com/Azure/go-ansiterm v0.0.0-20250102033503-faa5f7b0171c // indirect - github.com/atotto/clipboard v0.1.4 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect github.com/catppuccin/go v0.3.0 // indirect github.com/charmbracelet/colorprofile v0.4.3 // indirect From 4b78f518ed53984b2fdd056e47a1be4474c1e623 Mon Sep 17 00:00:00 2001 From: zAbuQasem Date: Tue, 16 Jun 2026 18:18:19 +0300 Subject: [PATCH 3/4] feat(tui): rework playground list, region handling, and key bindings - merge the persisted tab into playgrounds with a * marker and require double confirmation before destroying persistent labs - drop the ID column (now in the Info popup), add a REGION column, show AGE as elapsed/total, and make STATUS state-only - pick a region when spawning a lab (defaults to the account preference, applied via the preferences API) and show the region in the header - render all popups as overlays on top of the live view - consolidate exposing: w shares a web terminal, x exposes ports - allow SSH only on running labs, with a clear per-state message - replace the t restart key with the s start/stop toggle --- api/plays.go | 1 + cmd/tui/tui.go | 434 +++++++++++++++++++++++++++----------------- cmd/tui/tui_test.go | 248 +++++++++++++++++++++---- 3 files changed, 474 insertions(+), 209 deletions(-) diff --git a/api/plays.go b/api/plays.go index cc6fe2f..48e5585 100644 --- a/api/plays.go +++ b/api/plays.go @@ -57,6 +57,7 @@ type PlayStatus struct { type Play struct { ID string `json:"id" yaml:"id"` + Region string `json:"region,omitempty" yaml:"region,omitempty"` Title string `json:"title" yaml:"title"` CreatedAt string `json:"createdAt" yaml:"createdAt"` UpdatedAt string `json:"updatedAt" yaml:"updatedAt"` diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index b92b5b4..709596e 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -55,11 +55,10 @@ type viewTab int const ( tabCatalog viewTab = iota tabPlays - tabPersisted tabExports ) -const tabCount = 4 +const tabCount = 3 // exportItem is one exposed shell or port across all labs (Exports tab). type exportItem struct { @@ -72,15 +71,21 @@ type exportItem struct { } type ( - playsMsg struct{ plays, persisted []*api.Play } + playsMsg struct { + plays []*api.Play + persistedIDs map[string]bool + } catalogMsg struct{ items []api.Playground } actionMsg struct{ info string } errMsg struct{ err error } tickMsg struct{} authMsg struct { - ok bool - user string + ok bool + user string + region string } + regionSetMsg struct{ region string } + spawnedMsg struct{ id, name, region string } loginDoneMsg struct{ err error } disarmQuitMsg struct{} statusClearMsg struct{ seq int } @@ -109,7 +114,8 @@ func tick() tea.Cmd { } type pending struct { - id, name string + id, name string + persistent bool } // modal is the single active overlay/prompt. Exactly one is active at any time, @@ -120,7 +126,6 @@ const ( modalNone modal = iota modalFilter // footer search bar (renders within mainView) modalExtend // lifetime dialog - modalExport // export choice (web terminal / port) modalShare // share-terminal access choice (private/public) modalExposePort // expose-port number input modalAuth // sign-in popup @@ -129,23 +134,23 @@ const ( modalInfo // details popup modalThemes // theme picker (live preview) modalHelp // shortcuts popup + modalRegion // preferred-region picker ) type model struct { cli labcli.CLI - tab viewTab - playsTable table.Model - persistedTable table.Model - catalogTable table.Model - exportsTable table.Model - - plays []*api.Play // full, unfiltered - persisted []*api.Play // persistent plays (ListPlays{Persistent:true}) - catalog []api.Playground // full, unfiltered - exports []exportItem // aggregated exposed shells/ports - filteredPlays []*api.Play // rows currently shown (cursor indexes this) - filteredPers []*api.Play + tab viewTab + playsTable table.Model + catalogTable table.Model + exportsTable table.Model + + plays []*api.Play // full, unfiltered (incl. persistent) + persistedIDs map[string]bool // which plays are persistent (* marker) + spawnRegions map[string]string // playID -> region chosen at spawn (column fallback) + catalog []api.Playground // full, unfiltered + exports []exportItem // aggregated exposed shells/ports + filteredPlays []*api.Play // rows currently shown (cursor indexes this) filteredCat []api.Playground filteredExp []exportItem @@ -156,16 +161,19 @@ type model struct { extendBtn int // 0 = field, 1 = Cancel, 2 = OK extendID string // play being extended - exposeID string // play being shared / port-exposed - shareBtn int // 0 = Private, 1 = Public - exportBtn int // 0 = Web terminal, 1 = Port - infoShells []*api.Shell // exposed shells (loaded for the Info popup) - infoPorts []*api.Port // exposed ports (loaded for the Info popup) + exposeID string // play being shared / port-exposed + shareBtn int // 0 = Private, 1 = Public + regionBtn int // index into api.KnownRegions (modalRegion) + regionForSpawn bool // modalRegion is choosing a spawn region, not the default + spawnName string // catalog playground awaiting a region choice + infoShells []*api.Shell // exposed shells (loaded for the Info popup) + infoPorts []*api.Port // exposed ports (loaded for the Info popup) status string statusSeq int // bumped per flash; stale auto-clears are ignored confirm pending // destroy target (valid while modal == modalConfirm) confirmBtn int // 0 = Cancel, 1 = Destroy + confirmStage int // persistent destroy needs two confirmations (0 then 1) authBtn int // 0 = Dismiss, 1 = Login via browser authDismissed bool // auth popup already dismissed this session quitBtn int // 0 = Cancel, 1 = Quit @@ -174,22 +182,21 @@ type model struct { theme string // active color theme name themeIdx int // index into themeOrder for the T-key cycler user string // logged-in user id (from GetMe) + region string // preferred region for new playgrounds (from GetMe) defaulted bool // initial view default (catalog-if-empty) applied width, height int } func newModel(cli labcli.CLI) model { pt := table.New(table.WithFocused(true)) - pp := table.New() ct := table.New() ex := table.New() applySkin(&pt) - applySkin(&pp) applySkin(&ct) applySkin(&ex) ti := textinput.New() ti.Prompt = "" - m := model{cli: cli, tab: tabPlays, playsTable: pt, persistedTable: pp, catalogTable: ct, exportsTable: ex, input: ti, status: "Loading...", theme: "k9s"} + m := model{cli: cli, tab: tabPlays, playsTable: pt, catalogTable: ct, exportsTable: ex, input: ti, status: "Loading...", theme: "k9s", spawnRegions: map[string]string{}} m.setSizes(80, 24) return m } @@ -206,7 +213,7 @@ func (m model) checkAuth() tea.Cmd { if err != nil { return authMsg{ok: false} } - return authMsg{ok: true, user: me.ID} + return authMsg{ok: true, user: me.ID, region: me.PreferredRegion} } } @@ -240,18 +247,12 @@ func (m model) loadPlays() tea.Cmd { isPersistent[p.ID] = true } - // Playgrounds: active/stopped, non-persistent (persistent labs live only - // on the Persisted tab). - plays := slices.DeleteFunc(append([]*api.Play{}, recent...), func(p *api.Play) bool { - return gone(p) || isPersistent[p.ID] - }) + // One unified list of active/stopped plays; persistent ones are kept and + // flagged (rendered with a * marker) rather than split into their own tab. + plays := slices.DeleteFunc(append([]*api.Play{}, recent...), gone) slices.SortFunc(plays, byUpdated) - // Persisted: the persistent labs. - persisted := slices.DeleteFunc(append([]*api.Play{}, persistent...), gone) - slices.SortFunc(persisted, byUpdated) - - return playsMsg{plays: plays, persisted: persisted} + return playsMsg{plays: plays, persistedIDs: isPersistent} } } @@ -294,6 +295,18 @@ func (m model) persistPlay(id string) tea.Cmd { }) } +func (m model) setRegion(region string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 20*time.Second) + defer cancel() + me, err := m.cli.Client().SetPreferredRegion(ctx, region) + if err != nil { + return errMsg{err} + } + return regionSetMsg{region: me.PreferredRegion} + } +} + func (m model) extendPlay(id string, minutes int) tea.Cmd { return m.playAction(fmt.Sprintf("Lifetime of %s set to %dm", id, minutes), func(ctx context.Context) error { _, err := m.cli.Client().SetPlayMaxPlayTime(ctx, id, minutes) @@ -383,7 +396,7 @@ func (m model) loadExposed(id string) tea.Cmd { // loadExports aggregates exposed shells/ports across all active labs for the // Exports tab. func (m model) loadExports() tea.Cmd { - plays := append(append([]*api.Play{}, m.plays...), m.persisted...) + plays := append([]*api.Play{}, m.plays...) return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) defer cancel() @@ -430,15 +443,27 @@ func (m model) unexpose(e exportItem) tea.Cmd { } } -func (m model) startPlay(name string) tea.Cmd { - return m.playAction("Started "+name, func(ctx context.Context) error { +func (m model) startPlay(name, region string) tea.Cmd { + return func() tea.Msg { + ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) + defer cancel() + // Region is an account-level preference ("region for new playgrounds"), + // not a per-create field — set it first so the lab spawns in that region. + if region != "" { + if _, err := m.cli.Client().SetPreferredRegion(ctx, region); err != nil { + return errMsg{err} + } + } // ponytail: official catalog playgrounds need no safety consent; auto-ack. - _, err := m.cli.Client().CreatePlay(ctx, api.CreatePlayRequest{ + p, err := m.cli.Client().CreatePlay(ctx, api.CreatePlayRequest{ Playground: name, SafetyDisclaimerConsent: true, }) - return err - }) + if err != nil { + return errMsg{err} + } + return spawnedMsg{id: p.ID, name: name, region: region} + } } func (m model) playAction(info string, fn func(context.Context) error) tea.Cmd { @@ -452,20 +477,13 @@ func (m model) playAction(info string, fn func(context.Context) error) tea.Cmd { } } -// selectedPlay returns the highlighted play on the active plays-like tab. +// selectedPlay returns the highlighted play on the Playgrounds tab. func (m *model) selectedPlay() *api.Play { - var tbl *table.Model - var rows []*api.Play - switch m.tab { - case tabPlays: - tbl, rows = &m.playsTable, m.filteredPlays - case tabPersisted: - tbl, rows = &m.persistedTable, m.filteredPers - default: + if m.tab != tabPlays { return nil } - if i := tbl.Cursor(); i >= 0 && i < len(rows) { - return rows[i] + if i := m.playsTable.Cursor(); i >= 0 && i < len(m.filteredPlays) { + return m.filteredPlays[i] } return nil } @@ -479,12 +497,26 @@ func (m *model) selectedPlayground() *api.Playground { } // filterPlays returns the subset of plays matching f, along with their rows, -// keeping the cursor->slice mapping in sync. -func filterPlays(plays []*api.Play, f string) ([]*api.Play, []table.Row) { +// keeping the cursor->slice mapping in sync. Persistent plays get a * marker. +func filterPlays(plays []*api.Play, f string, persistedIDs map[string]bool, spawnRegions map[string]string) ([]*api.Play, []table.Row) { out := make([]*api.Play, 0, len(plays)) rows := make([]table.Row, 0, len(plays)) for _, p := range plays { - row := table.Row{p.ID, p.Playground.Name, shortStatus(p), humanize.Time(parseTime(p.CreatedAt))} + name := p.Playground.Name + if persistedIDs[p.ID] { + name = "* " + name + } + // Prefer the region the API returns; fall back to the one chosen at spawn. + region := p.Region + if region == "" { + region = spawnRegions[p.ID] + } + if region == "" { + region = "-" + } else { + region = strings.ToUpper(region) + } + row := table.Row{name, region, shortStatus(p), playAge(p)} if f == "" || strings.Contains(strings.ToLower(strings.Join(row, " ")), f) { out = append(out, p) rows = append(rows, row) @@ -498,11 +530,9 @@ func filterPlays(plays []*api.Play, f string) ([]*api.Play, []table.Row) { func (m *model) refreshRows() { f := strings.ToLower(strings.TrimSpace(m.filter)) - var pr, ppr []table.Row - m.filteredPlays, pr = filterPlays(m.plays, f) + var pr []table.Row + m.filteredPlays, pr = filterPlays(m.plays, f, m.persistedIDs, m.spawnRegions) m.playsTable.SetRows(pr) - m.filteredPers, ppr = filterPlays(m.persisted, f) - m.persistedTable.SetRows(ppr) m.filteredCat = m.filteredCat[:0] cr := make([]table.Row, 0, len(m.catalog)) @@ -544,7 +574,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case playsMsg: m.plays = msg.plays - m.persisted = msg.persisted + m.persistedIDs = msg.persistedIDs m.refreshRows() // Clear the transient progress statuses on a healthy poll (action results // and ✗ errors clear themselves via flash). @@ -573,6 +603,7 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { } if msg.ok { m.user = msg.user + m.region = msg.region m.authDismissed = false cmds := []tea.Cmd{m.loadPlays(), m.loadCatalog()} if fromLogin { // confirm the sign-in, then auto-clear @@ -651,6 +682,20 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.refreshRows() return m, nil + case regionSetMsg: + m.region = msg.region + return m, m.flash(okMark + " Region set to " + strings.ToUpper(msg.region)) + + case spawnedMsg: + // Remember the chosen region (the API doesn't return it per play) so the + // REGION column can show it for labs spawned in this session. Spawning + // also updates the account default, so reflect it in the header. + if msg.region != "" { + m.spawnRegions[msg.id] = msg.region + m.region = msg.region + } + return m, tea.Batch(m.loadPlays(), m.flash(okMark+" Started "+msg.name)) + case tea.KeyMsg: return m.handleKey(msg) } @@ -702,8 +747,6 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleThemesKey(msg) case modalExtend: return m.handleExtendSelectKey(msg) - case modalExport: - return m.handleExportKey(msg) case modalShare: return m.handleShareKey(msg) case modalExposePort: @@ -718,6 +761,8 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleAuthKey(msg) case modalConfirm: return m.handleConfirmKey(msg) + case modalRegion: + return m.handleRegionKey(msg) case modalHelp: // any key closes it m.modal = modalNone return m, nil @@ -761,6 +806,15 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.themePrevIdx = m.themeIdx return m, nil + case "R": // set the default preferred region for new playgrounds + m.modal = modalRegion + m.regionForSpawn = false + m.regionBtn = slices.Index(api.KnownRegions, m.region) + if m.regionBtn < 0 { + m.regionBtn = 0 + } + return m, nil + case "i": // show full details of the selected row m.infoShells, m.infoPorts = nil, nil if m.tab == tabCatalog { @@ -782,7 +836,7 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case tabExports: return m.handleExportsKey(msg) default: - return m.handlePlaysKey(msg) // tabPlays + tabPersisted share actions + return m.handlePlaysKey(msg) // tabPlays } } @@ -803,12 +857,9 @@ func (m model) tabEnterCmd() tea.Cmd { func (m *model) focusActiveTable() { m.playsTable.Blur() - m.persistedTable.Blur() m.catalogTable.Blur() m.exportsTable.Blur() switch m.tab { - case tabPersisted: - m.persistedTable.Focus() case tabCatalog: m.catalogTable.Focus() case tabExports: @@ -905,9 +956,16 @@ func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "enter": if m.confirmBtn == 1 { + // Persistent labs require a second confirmation before destroying. + if m.confirm.persistent && m.confirmStage == 0 { + m.confirmStage = 1 + m.confirmBtn = 0 // re-default to Cancel + return m, nil + } id := m.confirm.id m.modal = modalNone m.confirmBtn = 0 + m.confirmStage = 0 m.status = "Destroying..." return m, m.destroyPlay(id) } @@ -915,6 +973,7 @@ func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { case "esc": m.modal = modalNone m.confirmBtn = 0 + m.confirmStage = 0 m.status = "Cancelled" return m, nil default: @@ -922,31 +981,6 @@ func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } -// handleShareKey drives the share-terminal access choice (Private/Public). -// handleExportKey drives the Export choice (Web terminal / Port). -func (m model) handleExportKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { - switch msg.String() { - case "up", "down", "left", "right", "tab", "j", "k", "h", "l": - m.exportBtn = 1 - m.exportBtn - return m, nil - case "esc": - m.modal = modalNone - return m, nil - case "enter": - if m.exportBtn == 0 { // Web terminal -> access choice - m.modal = modalShare - m.shareBtn = 0 - return m, nil - } - m.modal = modalExposePort // Port -> number input - m.input.SetValue("") - m.input.Placeholder = "ports e.g. 8080, 9090" - return m, m.input.Focus() - default: - return m, nil - } -} - func (m model) handleShareKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "down", "left", "right", "tab", "j", "k", "h", "l": @@ -1003,6 +1037,37 @@ func (m model) handleExposePortKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// handleRegionKey drives the preferred-region picker (EU/AP). enter applies. +func (m model) handleRegionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "down", "left", "right", "tab", "j", "k", "h", "l": + m.regionBtn = (m.regionBtn + 1) % len(api.KnownRegions) + return m, nil + case "esc": + m.modal = modalNone + m.regionForSpawn = false + return m, nil + case "enter": + region := api.KnownRegions[m.regionBtn] + m.modal = modalNone + if m.regionForSpawn { // spawn the catalog playground in the chosen region + name := m.spawnName + m.regionForSpawn = false + m.tab = tabPlays + m.focusActiveTable() + m.status = "Starting " + name + "..." + return m, m.startPlay(name, region) + } + if region == m.region { + return m, nil + } + m.status = "Setting region..." + return m, m.setRegion(region) + default: + return m, nil + } +} + // handlePromptKey drives the filter input (live-filters as you type). func (m model) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { @@ -1031,8 +1096,13 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { p := m.selectedPlay() switch msg.String() { case "enter": // SSH by handing the terminal to `labctl ssh `. - if p == nil || !p.IsActive() { - m.status = errMark + " Select a running playground to SSH" + if p == nil { + return m, nil + } + // SSH only works on a RUNNING lab; every other state (stopped, stopping, + // starting, warming up, …) can't accept a session. + if !p.StateIs(api.StateRunning) { + m.status = errMark + " Can't SSH into a " + shortStatus(p) + " lab — press ? for help" return m, nil } c := exec.Command(os.Args[0], "ssh", p.ID) @@ -1060,20 +1130,15 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } m.status = "Stopping..." return m, m.stopPlay(p.ID) - case "t": - if p == nil { - return m, nil - } - m.status = "Restarting..." - return m, m.restartPlay(p.ID) - case "P": // make the playground persistent (Playgrounds tab, active labs only) - if m.tab != tabPlays { - return m, nil // already persistent / not a playground - } + case "P": // make the playground persistent (active, non-persistent labs only) if p == nil || !p.IsActive() { m.status = errMark + " Select a running playground to persist" return m, nil } + if m.persistedIDs[p.ID] { + m.status = errMark + " Already persistent" + return m, nil + } m.status = "Persisting..." return m, m.persistPlay(p.ID) case "e": // extend lifetime (opens the lifetime dialog) @@ -1096,7 +1161,7 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.exposeID = p.ID m.shareBtn = 0 // default to Private return m, nil - case "E": // expose an HTTP port + case "x": // expose HTTP port(s) if p == nil || !p.IsActive() { m.status = errMark + " Select a running playground to expose a port" return m, nil @@ -1104,23 +1169,15 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.modal = modalExposePort m.exposeID = p.ID m.input.SetValue("") - m.input.Placeholder = "port e.g. 8080" + m.input.Placeholder = "ports e.g. 8080, 9090" return m, m.input.Focus() - case "x": // open the Export dialog (web terminal / port) - if p == nil || !p.IsActive() { - m.status = errMark + " Select a running playground to export" - return m, nil - } - m.modal = modalExport - m.exposeID = p.ID - m.exportBtn = 0 - return m, nil case "ctrl+d": // destroy the playground (with confirmation) if p == nil { return m, nil } - m.confirm = pending{id: p.ID, name: p.Playground.Name} + m.confirm = pending{id: p.ID, name: p.Playground.Name, persistent: m.persistedIDs[p.ID]} m.confirmBtn = 0 // default-highlight Cancel for a destructive action + m.confirmStage = 0 m.modal = modalConfirm m.status = "" return m, nil @@ -1165,10 +1222,15 @@ func (m model) handleCatalogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if pg == nil { return m, nil } - m.tab = tabPlays - m.focusActiveTable() - m.status = "Starting " + pg.Name + "..." - return m, m.startPlay(pg.Name) + // Pick a region before spawning, defaulted to the platform preference. + m.modal = modalRegion + m.regionForSpawn = true + m.spawnName = pg.Name + m.regionBtn = slices.Index(api.KnownRegions, m.region) + if m.regionBtn < 0 { + m.regionBtn = 0 + } + return m, nil } cmd := m.delegate(msg) return m, cmd @@ -1179,8 +1241,6 @@ func (m model) handleCatalogKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { func (m *model) delegate(msg tea.Msg) tea.Cmd { var cmd tea.Cmd switch m.tab { - case tabPersisted: - m.persistedTable, cmd = m.persistedTable.Update(msg) case tabCatalog: m.catalogTable, cmd = m.catalogTable.Update(msg) case tabExports: @@ -1234,22 +1294,18 @@ func (m *model) setSizes(w, h int) { bodyH = 3 } m.playsTable.SetHeight(bodyH) - m.persistedTable.SetHeight(bodyH) m.catalogTable.SetHeight(bodyH) inner := w - 2 // titledBox eats one column per side - pc := distribute(inner-6, []int{3, 2, 3, 2}, []int{10, 8, 8, 6}) // ID NAME STATUS AGE - playCols := []table.Column{ - {Title: "ID", Width: pc[0]}, - {Title: "NAME", Width: pc[1]}, + pc := distribute(inner-6, []int{4, 1, 2, 2}, []int{12, 6, 8, 9}) // NAME REGION STATUS AGE + m.playsTable.SetColumns([]table.Column{ + {Title: "NAME", Width: pc[0]}, + {Title: "REGION", Width: pc[1]}, {Title: "STATUS", Width: pc[2]}, {Title: "AGE", Width: pc[3]}, - } - m.playsTable.SetColumns(playCols) + }) m.playsTable.SetWidth(inner) - m.persistedTable.SetColumns(playCols) - m.persistedTable.SetWidth(inner) cc := distribute(inner-4, []int{1, 3}, []int{12, 15}) // PLAYGROUND DESCRIPTION m.catalogTable.SetColumns([]table.Column{ @@ -1273,12 +1329,43 @@ func shortStatus(p *api.Play) string { if st == "" { return "UNKNOWN" } - if p.StateIs(api.StateRunning) { - return "RUNNING " + humanize.Time(time.Now().Add(time.Duration(p.ExpiresIn)*time.Millisecond)) - } return string(st) } +// playAge renders the elapsed/total lifetime, e.g. "12m/1h". For running labs +// total = elapsed + remaining (ExpiresIn); otherwise just the elapsed time. +func playAge(p *api.Play) string { + created := parseTime(p.CreatedAt) + if created.IsZero() { + return "-" + } + elapsed := time.Since(created) + if elapsed < 0 { + elapsed = 0 + } + if p.StateIs(api.StateRunning) && p.ExpiresIn > 0 { + total := elapsed + time.Duration(p.ExpiresIn)*time.Millisecond + return fmtDur(elapsed) + "/" + fmtDur(total) + } + return fmtDur(elapsed) +} + +// fmtDur renders a compact duration: 45s, 12m, 1h, 3h20m. +func fmtDur(d time.Duration) string { + switch { + case d < time.Minute: + return fmt.Sprintf("%ds", int(d.Seconds())) + case d < time.Hour: + return fmt.Sprintf("%dm", int(d.Minutes())) + default: + h := int(d.Hours()) + if mins := int(d.Minutes()) % 60; mins > 0 { + return fmt.Sprintf("%dh%dm", h, mins) + } + return fmt.Sprintf("%dh", h) + } +} + func parseTime(s string) time.Time { t, _ := time.Parse(time.RFC3339, s) return t @@ -1567,8 +1654,6 @@ func (m model) View() string { return m.quitView() case modalExtend: return m.extendSelectView() - case modalExport: - return m.exportView() case modalShare: return m.shareView() case modalExposePort: @@ -1577,6 +1662,8 @@ func (m model) View() string { return m.infoView() case modalThemes: return m.themeView() + case modalRegion: + return m.regionView() case modalHelp: return m.helpView() default: // modalNone or modalFilter (filter renders as a footer bar) @@ -1589,8 +1676,6 @@ func (m model) mainView() string { var body string switch m.tab { - case tabPersisted: - body = m.persistedTable.View() case tabCatalog: body = m.catalogTable.View() case tabExports: @@ -1635,8 +1720,6 @@ func (m model) tabsTitle() string { } var count int switch m.tab { - case tabPersisted: - count = len(m.filteredPers) case tabCatalog: count = len(m.filteredCat) case tabExports: @@ -1650,7 +1733,6 @@ func (m model) tabsTitle() string { } return tab("catalog", m.tab == tabCatalog) + " " + tab("playgrounds", m.tab == tabPlays) + " " + - tab("persisted", m.tab == tabPersisted) + " " + tab("exports", m.tab == tabExports) + dialogTitle.Render(suffix) } @@ -1660,7 +1742,6 @@ func (m *model) applyTheme(name string) { m.theme = name initStyles(presets[name]) applySkin(&m.playsTable) - applySkin(&m.persistedTable) applySkin(&m.catalogTable) applySkin(&m.exportsTable) } @@ -1797,19 +1878,27 @@ func (m model) extendSelectView() string { })) } -func (m model) quitView() string { - return m.centered(kDialog("Quit", []string{ - menuText.Render("Quit labctl?"), +func (m model) regionView() string { + labels := make([]string, len(api.KnownRegions)) + for i, r := range api.KnownRegions { + labels[i] = strings.ToUpper(r) + } + title, msg := "Preferred region", "Region for new playgrounds:" + if m.regionForSpawn { + title, msg = "Spawn region", "Spawn "+m.spawnName+" in region:" + } + return m.overlayCentered(kDialog(title, []string{ + menuText.Render(msg), "", - buttonRow([]string{"Cancel", "Quit"}, m.quitBtn), + buttonRow(labels, m.regionBtn), })) } -func (m model) exportView() string { - return m.overlayCentered(kDialog("Export", []string{ - menuText.Render("What to expose?"), +func (m model) quitView() string { + return m.overlayCentered(kDialog("Quit", []string{ + menuText.Render("Quit labctl?"), "", - buttonRow([]string{"Web terminal", "Port"}, m.exportBtn), + buttonRow([]string{"Cancel", "Quit"}, m.quitBtn), })) } @@ -1877,7 +1966,6 @@ func (m model) infoView() string { title = p.Playground.Name fields = []string{ infoField("ID", p.ID), - infoField("Title", p.Title), infoField("Status", shortStatus(p)), infoField("Created", humanize.Time(parseTime(p.CreatedAt))), infoField("Lifetime", p.MaxPlayTime), @@ -1898,7 +1986,7 @@ func (m model) infoView() string { } parts = append(parts, "", helpStyle.Render("o open in browser · any key to close")) - return m.centered(titledBox(dialogTitle.Render(title), strings.Join(parts, "\n"), contentW+2)) + return m.overlayCentered(titledBox(dialogTitle.Render(title), strings.Join(parts, "\n"), contentW+2)) } // exposedLines formats the loaded exposed shells/ports for the Info popup. @@ -1919,9 +2007,16 @@ func (m model) headerView(w int) string { if user == "" { user = "-" } + region := m.region + if region == "" { + region = "-" + } else { + region = strings.ToUpper(region) + } info := lipgloss.JoinVertical(lipgloss.Left, infoLine("Labs", strings.TrimPrefix(m.cli.Config().BaseURL, "https://")), infoLine("User", user), + infoLine("Region", region), infoLine("Plays", strconv.Itoa(len(m.plays))), infoLine("Catalog", strconv.Itoa(len(m.catalog))), ) @@ -1945,7 +2040,7 @@ func (m model) headerView(w int) string { return strings.Join(lines[:headerRows], "\n") } -const headerRows = 4 // header pinned to this many rows for cross-tab stability +const headerRows = 5 // header pinned to this many rows for cross-tab stability // Below this the layout can't render usefully; show a "too small" message. const ( @@ -1968,17 +2063,11 @@ func (m model) menuView() string { {"enter", "Copy"}, {"o", "Open"}, {"ctrl+d", "Unexpose"}, {":", "Filter"}, {"r", "Refresh"}, {"?", "Shortcuts"}, } - case tabPersisted: - items = [][2]string{ - {"enter", "SSH"}, {"i", "Info"}, {"w", "Share"}, - {"E", "Expose"}, {"x", "Export"}, {":", "Filter"}, - {"r", "Refresh"}, {"tab", "Switch"}, {"?", "Shortcuts"}, - } default: // tabPlays items = [][2]string{ {"enter", "SSH"}, {"i", "Info"}, {"w", "Share"}, - {"E", "Expose"}, {"x", "Export"}, {"P", "Persist"}, - {":", "Filter"}, {"r", "Refresh"}, {"?", "Shortcuts"}, + {"x", "Ports"}, {"P", "Persist"}, {":", "Filter"}, + {"r", "Refresh"}, {"tab", "Switch"}, {"?", "Shortcuts"}, } } return menuColumns(items) @@ -2023,7 +2112,6 @@ func (m model) helpView() string { key("o", "open in browser"), key("i", "info"), key("s", "start/stop toggle"), - key("t", "restart"), key("P", "persist"), key("e", "extend lifetime"), key("ctrl+d", "destroy"), @@ -2031,8 +2119,7 @@ func (m model) helpView() string { right := strings.Join([]string{ hdr("Export"), key("w", "share terminal"), - key("E", "expose port(s)"), - key("x", "export dialog"), + key("x", "expose port(s)"), "", hdr("Exports tab"), key("enter", "copy url"), @@ -2041,17 +2128,18 @@ func (m model) helpView() string { "", hdr("General"), key("r", "refresh"), + key("R", "preferred region"), key("T", "theme picker"), key("q", "quit"), key("?", "close help"), }, "\n") body := lipgloss.JoinHorizontal(lipgloss.Top, left, " ", right) - return m.centered(titledBox(dialogTitle.Render("Shortcuts"), body, 72)) + return m.overlayCentered(titledBox(dialogTitle.Render("Shortcuts"), body, 72)) } func (m model) authView() string { - return m.centered(kDialog("Sign in", []string{ + return m.overlayCentered(kDialog("Sign in", []string{ menuText.Render("Not signed in to iximiuz Labs"), "", buttonRow([]string{"Dismiss", "Login via browser"}, m.authBtn), @@ -2059,8 +2147,16 @@ func (m model) authView() string { } func (m model) confirmView() string { + msg := "Destroy " + m.confirm.name + "?" + if m.confirm.persistent { + if m.confirmStage == 0 { + msg = "Destroy PERSISTENT lab " + m.confirm.name + "?" + } else { + msg = "Confirm again — permanently destroy " + m.confirm.name + "?" + } + } return m.overlayCentered(kDialog("Confirm", []string{ - menuText.Render("Destroy " + m.confirm.name + "?"), + menuText.Render(msg), infoVal.Render(m.confirm.id), "", buttonRow([]string{"Cancel", "Destroy"}, m.confirmBtn), diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index 0897c72..f7b2a04 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -3,8 +3,10 @@ package tui import ( "bytes" "io" + "slices" "strings" "testing" + "time" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" @@ -328,31 +330,21 @@ func TestShareTerminalOpens(t *testing.T) { } } -// TestExportDialogRoutes guards that x opens the Export dialog and routes to the -// share / expose-port sub-flows. -func TestExportDialogRoutes(t *testing.T) { +// TestExposePortsDirect guards that x goes straight to the port-expose input +// (the old web-terminal/port chooser is gone; w handles terminals now). +func TestExposePortsDirect(t *testing.T) { t.Parallel() - open := func() model { - m := newModel(testCLI()) - m.plays = []*api.Play{playWithState("p1", api.StateRunning)} - m.refreshRows() - out, _ := m.handlePlaysKey(runeKey("x")) - return out.(model) - } - if m := open(); m.modal != modalExport { - t.Fatalf("x: modal = %v, want modalExport", m.modal) - } - // Web terminal (button 0) -> share access choice. - web, _ := open().handleExportKey(tea.KeyMsg{Type: tea.KeyEnter}) - if web.(model).modal != modalShare { - t.Fatalf("Export>Web: modal = %v, want modalShare", web.(model).modal) + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("p1", api.StateRunning)} + m.refreshRows() + + out, _ := m.handlePlaysKey(runeKey("x")) + mo := out.(model) + if mo.modal != modalExposePort { + t.Fatalf("x: modal = %v, want modalExposePort", mo.modal) } - // Port (button 1) -> port input. - m := open() - m.exportBtn = 1 - port, _ := m.handleExportKey(tea.KeyMsg{Type: tea.KeyEnter}) - if port.(model).modal != modalExposePort { - t.Fatalf("Export>Port: modal = %v, want modalExposePort", port.(model).modal) + if mo.exposeID != "p1" { + t.Fatalf("exposeID = %q, want p1", mo.exposeID) } } @@ -443,43 +435,49 @@ func TestExposePortValidation(t *testing.T) { } } -// TestPersistOnlyOnPlaygrounds guards that P is a no-op on the Persisted tab -// (persisted labs are already persistent). -func TestPersistOnlyOnPlaygrounds(t *testing.T) { +// TestPersistNoOpWhenAlreadyPersistent guards that P is rejected for a lab that +// is already persistent (marked via persistedIDs). +func TestPersistNoOpWhenAlreadyPersistent(t *testing.T) { t.Parallel() m := newModel(testCLI()) - m.persisted = []*api.Play{playWithState("p1", api.StateRunning)} + m.plays = []*api.Play{playWithState("p1", api.StateRunning)} + m.persistedIDs = map[string]bool{"p1": true} m.refreshRows() - m.tab = tabPersisted out, _ := m.handlePlaysKey(runeKey("P")) if got := out.(model).status; got == "Persisting..." { - t.Fatal("persist should be a no-op on the Persisted tab") + t.Fatal("persist should be a no-op for an already-persistent lab") } } -// TestPersistedTabSelection guards that on the Persisted tab, selectedPlay -// indexes the persisted slice (not the plays slice). -func TestPersistedTabSelection(t *testing.T) { +// TestPersistentMarker guards that persistent plays render with a * prefix in +// the merged Playgrounds list, and selectedPlay still maps correctly. +func TestPersistentMarker(t *testing.T) { t.Parallel() m := newModel(testCLI()) - m.plays = []*api.Play{playWithState("aaa", api.StateRunning)} - m.persisted = []*api.Play{playWithState("bbb", api.StateStopped)} + m.plays = []*api.Play{ + playWithState("aaa", api.StateRunning), + playWithState("bbb", api.StateStopped), + } + m.persistedIDs = map[string]bool{"bbb": true} m.refreshRows() - m.switchTab(1) // playgrounds -> persisted - if m.tab != tabPersisted { - t.Fatalf("tab = %v, want tabPersisted", m.tab) + rows := m.playsTable.Rows() + if len(rows) != 2 { + t.Fatalf("rows = %d, want 2", len(rows)) } - if p := m.selectedPlay(); p == nil || p.ID != "bbb" { - t.Fatalf("selectedPlay = %v, want persisted play bbb", p) + if !strings.HasPrefix(rows[1][0], "* ") { + t.Fatalf("persistent row name = %q, want a * prefix", rows[1][0]) + } + if strings.HasPrefix(rows[0][0], "*") { + t.Fatalf("non-persistent row name = %q, should have no * prefix", rows[0][0]) } } func TestSwitchTabCycles(t *testing.T) { t.Parallel() m := newModel(testCLI()) - for _, want := range []viewTab{tabPersisted, tabExports, tabCatalog, tabPlays} { + for _, want := range []viewTab{tabExports, tabCatalog, tabPlays} { m.switchTab(1) if m.tab != want { t.Fatalf("after +1, tab = %v, want %v", m.tab, want) @@ -528,3 +526,173 @@ func TestShortStatus(t *testing.T) { }) } } + +// TestRegionPickerPreselects guards that `R` opens the region picker with the +// button preselected to the user's current region. +func TestRegionPickerPreselects(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.region = api.RegionAP + out, _ := m.handleKey(runeKey("R")) + mo := out.(model) + if mo.modal != modalRegion { + t.Fatalf("modal = %v, want modalRegion", mo.modal) + } + if got, want := mo.regionBtn, slices.Index(api.KnownRegions, api.RegionAP); got != want { + t.Fatalf("regionBtn = %d, want %d", got, want) + } +} + +// TestRegionSetUpdatesModel guards that a successful region set updates the +// header value. +func TestRegionSetUpdatesModel(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + out, _ := m.Update(regionSetMsg{region: api.RegionEU}) + if got := out.(model).region; got != api.RegionEU { + t.Fatalf("region = %q, want %q", got, api.RegionEU) + } +} + +// TestSpawnRegionPicker guards that pressing enter on a catalog playground opens +// the region picker in spawn mode, defaulted to the platform region. +func TestSpawnRegionPicker(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.catalog = []api.Playground{{Name: "docker"}} + m.region = api.RegionAP + m.refreshRows() + m.tab = tabCatalog + m.focusActiveTable() + + out, _ := m.handleCatalogKey(tea.KeyMsg{Type: tea.KeyEnter}) + mo := out.(model) + if mo.modal != modalRegion || !mo.regionForSpawn { + t.Fatalf("modal=%v regionForSpawn=%v, want modalRegion/true", mo.modal, mo.regionForSpawn) + } + if mo.spawnName != "docker" { + t.Fatalf("spawnName = %q, want docker", mo.spawnName) + } + if want := slices.Index(api.KnownRegions, api.RegionAP); mo.regionBtn != want { + t.Fatalf("regionBtn = %d, want %d (default to platform region)", mo.regionBtn, want) + } +} + +// TestSpawnRegionStarts guards that confirming the spawn-region picker starts the +// lab and returns to the Playgrounds tab. +func TestSpawnRegionStarts(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.regionForSpawn = true + m.spawnName = "docker" + m.modal = modalRegion + m.regionBtn = 0 + + out, cmd := m.handleRegionKey(tea.KeyMsg{Type: tea.KeyEnter}) + mo := out.(model) + if mo.modal != modalNone || mo.regionForSpawn { + t.Fatalf("modal=%v regionForSpawn=%v, want modalNone/false", mo.modal, mo.regionForSpawn) + } + if mo.tab != tabPlays { + t.Fatalf("tab = %v, want tabPlays", mo.tab) + } + if !strings.HasPrefix(mo.status, "Starting docker") { + t.Fatalf("status = %q, want Starting docker...", mo.status) + } + if cmd == nil { + t.Fatal("spawn should return a start command") + } +} + +// TestPersistentDoubleConfirm guards that destroying a persistent lab needs two +// confirmations, while a normal lab needs one. +func TestPersistentDoubleConfirm(t *testing.T) { + t.Parallel() + enter := tea.KeyMsg{Type: tea.KeyEnter} + + // Persistent: first Destroy advances to stage 1 without destroying. + m := newModel(testCLI()) + m.confirm = pending{id: "p1", name: "pg", persistent: true} + m.confirmBtn = 1 + m.modal = modalConfirm + out, cmd := m.handleConfirmKey(enter) + mo := out.(model) + if mo.modal != modalConfirm || mo.confirmStage != 1 { + t.Fatalf("first confirm: modal=%v stage=%d, want modalConfirm/1", mo.modal, mo.confirmStage) + } + if cmd != nil { + t.Fatal("first confirm on a persistent lab must not destroy yet") + } + // Second Destroy actually destroys. + mo.confirmBtn = 1 + out2, cmd2 := mo.handleConfirmKey(enter) + if out2.(model).modal != modalNone || cmd2 == nil { + t.Fatal("second confirm should destroy and close") + } + + // Non-persistent: a single Destroy is enough. + n := newModel(testCLI()) + n.confirm = pending{id: "p2", name: "pg", persistent: false} + n.confirmBtn = 1 + n.modal = modalConfirm + out3, cmd3 := n.handleConfirmKey(enter) + if out3.(model).modal != modalNone || cmd3 == nil { + t.Fatal("non-persistent lab should destroy on the first confirm") + } +} + +func TestFmtDur(t *testing.T) { + t.Parallel() + tests := []struct { + d time.Duration + want string + }{ + {0, "0s"}, + {45 * time.Second, "45s"}, + {30 * time.Minute, "30m"}, + {60 * time.Minute, "1h"}, + {90 * time.Minute, "1h30m"}, + } + for _, tt := range tests { + if got := fmtDur(tt.d); got != tt.want { + t.Fatalf("fmtDur(%s) = %q, want %q", tt.d, got, tt.want) + } + } +} + +// TestPlayAge guards that a running lab shows elapsed/total and a stopped one +// shows just the elapsed time (no slash). +func TestPlayAge(t *testing.T) { + t.Parallel() + running := playWithState("p", api.StateRunning) + running.CreatedAt = time.Now().Add(-10 * time.Minute).Format(time.RFC3339) + running.ExpiresIn = int((50 * time.Minute) / time.Millisecond) + if got := playAge(running); !strings.Contains(got, "/") { + t.Fatalf("running playAge = %q, want elapsed/total", got) + } + + stopped := playWithState("p", api.StateStopped) + stopped.CreatedAt = time.Now().Add(-10 * time.Minute).Format(time.RFC3339) + if got := playAge(stopped); strings.Contains(got, "/") { + t.Fatalf("stopped playAge = %q, want elapsed only (no slash)", got) + } +} + +// TestSpawnRegionCached guards that the region chosen at spawn is remembered and +// shown in the REGION column (the API returns no per-play region). +func TestSpawnRegionCached(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + + out, _ := m.Update(spawnedMsg{id: "p1", name: "docker", region: api.RegionAP}) + mo := out.(model) + if got := mo.spawnRegions["p1"]; got != api.RegionAP { + t.Fatalf("spawnRegions[p1] = %q, want %q", got, api.RegionAP) + } + + mo.plays = []*api.Play{playWithState("p1", api.StateRunning)} // API play has no region + mo.refreshRows() + if got := mo.playsTable.Rows()[0][1]; got != strings.ToUpper(api.RegionAP) { + t.Fatalf("REGION cell = %q, want %q (from spawn cache)", got, strings.ToUpper(api.RegionAP)) + } +} From a6dc15c991095104e19f613f58aa20e26b60a666 Mon Sep 17 00:00:00 2001 From: zAbuQasem Date: Wed, 17 Jun 2026 07:29:38 +0300 Subject: [PATCH 4/4] feat(tui): background port-forwards + persisted theme/state - F starts a local background port-forward on the selected running lab; ctrl+f stops a lab's forwards (with confirmation). Forwards show in a dedicated PF column as local<->remote and are dropped when the lab is stopped, restarted, or destroyed - reject forwarding a local port that's already in use - persist the theme choice to ~/.labctl.config and per-lab regions plus live forwards to ~/.labctl.rc; restore running labs' forwards on reopen - show TTL (remaining lifetime, counting down) instead of elapsed age --- cmd/tui/state.go | 70 ++++++++++ cmd/tui/tui.go | 319 ++++++++++++++++++++++++++++++++++++++++---- cmd/tui/tui_test.go | 116 ++++++++++++++-- 3 files changed, 469 insertions(+), 36 deletions(-) create mode 100644 cmd/tui/state.go diff --git a/cmd/tui/state.go b/cmd/tui/state.go new file mode 100644 index 0000000..1fb727d --- /dev/null +++ b/cmd/tui/state.go @@ -0,0 +1,70 @@ +package tui + +import ( + "encoding/json" + "os" + "path/filepath" +) + +// ponytail: two tiny JSON files in $HOME, best-effort. Theme lives in +// ~/.labctl.config; per-lab region cache + live forwards in ~/.labctl.rc. + +func homePath(name string) string { + home, err := os.UserHomeDir() + if err != nil { + return "" + } + return filepath.Join(home, name) +} + +func loadJSON(path string, v any) { + if path == "" { + return + } + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, v) + } +} + +func saveJSON(path string, v any) { + if path == "" { + return + } + if data, err := json.MarshalIndent(v, "", " "); err == nil { + _ = os.WriteFile(path, data, 0o600) + } +} + +type uiConfig struct { + Theme string `json:"theme,omitempty"` +} + +func loadUIConfig() uiConfig { + var c uiConfig + loadJSON(homePath(".labctl.config"), &c) + return c +} + +func (c uiConfig) save() { saveJSON(homePath(".labctl.config"), c) } + +type savedForward struct { + PlayID string `json:"playID"` + PlayName string `json:"playName"` + Spec string `json:"spec"` +} + +type uiState struct { + Regions map[string]string `json:"regions,omitempty"` + Forwards []savedForward `json:"forwards,omitempty"` +} + +func loadUIState() uiState { + s := uiState{Regions: map[string]string{}} + loadJSON(homePath(".labctl.rc"), &s) + if s.Regions == nil { + s.Regions = map[string]string{} + } + return s +} + +func (s uiState) save() { saveJSON(homePath(".labctl.rc"), s) } diff --git a/cmd/tui/tui.go b/cmd/tui/tui.go index 709596e..3c61566 100644 --- a/cmd/tui/tui.go +++ b/cmd/tui/tui.go @@ -25,6 +25,7 @@ import ( "github.com/iximiuz/labctl/internal/browser" "github.com/iximiuz/labctl/internal/config" "github.com/iximiuz/labctl/internal/labcli" + "github.com/iximiuz/labctl/internal/portforward" ) func NewCommand(cli labcli.CLI) *cobra.Command { @@ -41,6 +42,12 @@ func NewCommand(cli labcli.CLI) *cobra.Command { // Run launches the interactive TUI and blocks until the user exits. func Run(cli labcli.CLI) error { skin, name := loadSkin() + // ~/.labctl.config theme choice wins over skin.yaml's theme. + if c := loadUIConfig(); c.Theme != "" { + if s, ok := presets[c.Theme]; ok { + skin, name = s, c.Theme + } + } initStyles(skin) m := newModel(cli) m.theme = name @@ -70,6 +77,14 @@ type exportItem struct { isPort bool } +// forward is a live local background port-forward (labctl side -> playground). +type forward struct { + playID, playName string + spec string // the user's original spec, for restore + lport, rport string // for inline display + cancel context.CancelFunc +} + type ( playsMsg struct { plays []*api.Play @@ -84,8 +99,12 @@ type ( user string region string } - regionSetMsg struct{ region string } - spawnedMsg struct{ id, name, region string } + regionSetMsg struct{ region string } + spawnedMsg struct{ id, name, region string } + forwardStartedMsg struct { + playID, playName, spec, lport, rport string + cancel context.CancelFunc + } loginDoneMsg struct{ err error } disarmQuitMsg struct{} statusClearMsg struct{ seq int } @@ -123,18 +142,20 @@ type pending struct { type modal uint8 const ( - modalNone modal = iota - modalFilter // footer search bar (renders within mainView) - modalExtend // lifetime dialog - modalShare // share-terminal access choice (private/public) - modalExposePort // expose-port number input - modalAuth // sign-in popup - modalConfirm // destroy confirmation - modalQuit // quit confirmation - modalInfo // details popup - modalThemes // theme picker (live preview) - modalHelp // shortcuts popup - modalRegion // preferred-region picker + modalNone modal = iota + modalFilter // footer search bar (renders within mainView) + modalExtend // lifetime dialog + modalShare // share-terminal access choice (private/public) + modalExposePort // expose-port number input + modalAuth // sign-in popup + modalConfirm // destroy confirmation + modalQuit // quit confirmation + modalInfo // details popup + modalThemes // theme picker (live preview) + modalHelp // shortcuts popup + modalRegion // preferred-region picker + modalForward // start-port-forward input + modalStopForward // confirm stopping a lab's port-forwards ) type model struct { @@ -148,6 +169,9 @@ type model struct { plays []*api.Play // full, unfiltered (incl. persistent) persistedIDs map[string]bool // which plays are persistent (* marker) spawnRegions map[string]string // playID -> region chosen at spawn (column fallback) + forwards []forward // live local background port-forwards + restore []savedForward // forwards to re-establish once plays load + restored bool // restore attempted catalog []api.Playground // full, unfiltered exports []exportItem // aggregated exposed shells/ports filteredPlays []*api.Play // rows currently shown (cursor indexes this) @@ -162,6 +186,7 @@ type model struct { extendID string // play being extended exposeID string // play being shared / port-exposed + fwdTarget pending // lab awaiting a port-forward spec (modalForward) shareBtn int // 0 = Private, 1 = Public regionBtn int // index into api.KnownRegions (modalRegion) regionForSpawn bool // modalRegion is choosing a spawn region, not the default @@ -196,11 +221,21 @@ func newModel(cli labcli.CLI) model { applySkin(&ex) ti := textinput.New() ti.Prompt = "" - m := model{cli: cli, tab: tabPlays, playsTable: pt, catalogTable: ct, exportsTable: ex, input: ti, status: "Loading...", theme: "k9s", spawnRegions: map[string]string{}} + st := loadUIState() + m := model{cli: cli, tab: tabPlays, playsTable: pt, catalogTable: ct, exportsTable: ex, input: ti, status: "Loading...", theme: "k9s", spawnRegions: st.Regions, restore: st.Forwards} m.setSizes(80, 24) return m } +// persistState writes the per-lab region cache and live forwards to ~/.labctl.rc. +func (m model) persistState() { + st := uiState{Regions: m.spawnRegions} + for _, f := range m.forwards { + st.Forwards = append(st.Forwards, savedForward{PlayID: f.playID, PlayName: f.playName, Spec: f.spec}) + } + st.save() +} + func (m model) Init() tea.Cmd { return tea.Batch(m.loadPlays(), m.loadCatalog(), m.checkAuth(), tick()) } @@ -466,6 +501,80 @@ func (m model) startPlay(name, region string) tea.Cmd { } } +// startForward establishes a local background port-forward to the lab. The +// tunnel lives under its own context so it keeps running after this cmd returns; +// the returned cancel func (in forwardStartedMsg) stops it. +func (m model) startForward(playID, playName, spec string) tea.Cmd { + return func() tea.Msg { + fs, err := portforward.ParseLocal(spec) + if err != nil { + return errMsg{fmt.Errorf("invalid port spec %q", spec)} + } + // A local port can only be bound once — reject a duplicate up front + // instead of silently failing to bind. + for _, f := range m.forwards { + if f.lport == fs.LocalPort { + return errMsg{fmt.Errorf("local port %s is already forwarded", fs.LocalPort)} + } + } + ctx, cancel := context.WithCancel(context.Background()) + setup, setupCancel := context.WithTimeout(ctx, 30*time.Second) + p, err := m.cli.Client().GetPlay(setup, playID) + if err != nil { + setupCancel() + cancel() + return errMsg{err} + } + machine, err := p.ResolveMachine("") + if err != nil { + setupCancel() + cancel() + return errMsg{err} + } + tunnel, err := portforward.StartTunnel(ctx, m.cli.Client(), portforward.TunnelOptions{ + PlayID: playID, Machine: machine, + }) + setupCancel() + if err != nil { + cancel() + return errMsg{err} + } + tunnel.StartForwarding(ctx, fs) // runs in the background under ctx + return forwardStartedMsg{ + playID: playID, playName: playName, spec: spec, + lport: fs.LocalPort, rport: fs.RemotePort, cancel: cancel, + } + } +} + +// stopForwards cancels and removes every forward on the given lab, returning the +// count stopped. +func (m *model) stopForwards(playID string) int { + kept := m.forwards[:0] + n := 0 + for _, f := range m.forwards { + if f.playID == playID { + if f.cancel != nil { + f.cancel() + } + n++ + continue + } + kept = append(kept, f) + } + m.forwards = kept + return n +} + +// fwdPortsByPlay maps a lab id to its "local<->remote" forwards (PF column). +func (m model) fwdPortsByPlay() map[string][]string { + out := make(map[string][]string, len(m.forwards)) + for _, f := range m.forwards { + out[f.playID] = append(out[f.playID], f.lport+"<->"+f.rport) + } + return out +} + func (m model) playAction(info string, fn func(context.Context) error) tea.Cmd { return func() tea.Msg { ctx, cancel := context.WithTimeout(context.Background(), 90*time.Second) @@ -498,7 +607,7 @@ func (m *model) selectedPlayground() *api.Playground { // filterPlays returns the subset of plays matching f, along with their rows, // keeping the cursor->slice mapping in sync. Persistent plays get a * marker. -func filterPlays(plays []*api.Play, f string, persistedIDs map[string]bool, spawnRegions map[string]string) ([]*api.Play, []table.Row) { +func filterPlays(plays []*api.Play, f string, persistedIDs map[string]bool, spawnRegions map[string]string, fwdPorts map[string][]string) ([]*api.Play, []table.Row) { out := make([]*api.Play, 0, len(plays)) rows := make([]table.Row, 0, len(plays)) for _, p := range plays { @@ -506,6 +615,7 @@ func filterPlays(plays []*api.Play, f string, persistedIDs map[string]bool, spaw if persistedIDs[p.ID] { name = "* " + name } + fwd := strings.Join(fwdPorts[p.ID], ",") // Prefer the region the API returns; fall back to the one chosen at spawn. region := p.Region if region == "" { @@ -516,7 +626,7 @@ func filterPlays(plays []*api.Play, f string, persistedIDs map[string]bool, spaw } else { region = strings.ToUpper(region) } - row := table.Row{name, region, shortStatus(p), playAge(p)} + row := table.Row{name, region, shortStatus(p), playAge(p), fwd} if f == "" || strings.Contains(strings.ToLower(strings.Join(row, " ")), f) { out = append(out, p) rows = append(rows, row) @@ -531,7 +641,7 @@ func (m *model) refreshRows() { f := strings.ToLower(strings.TrimSpace(m.filter)) var pr []table.Row - m.filteredPlays, pr = filterPlays(m.plays, f, m.persistedIDs, m.spawnRegions) + m.filteredPlays, pr = filterPlays(m.plays, f, m.persistedIDs, m.spawnRegions, m.fwdPortsByPlay()) m.playsTable.SetRows(pr) m.filteredCat = m.filteredCat[:0] @@ -589,6 +699,26 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { m.focusActiveTable() } } + // Re-establish saved forwards once, for labs that are running again. + if !m.restored { + m.restored = true + running := make(map[string]bool, len(m.plays)) + for _, p := range m.plays { + if p.StateIs(api.StateRunning) { + running[p.ID] = true + } + } + var cmds []tea.Cmd + for _, sf := range m.restore { + if running[sf.PlayID] { + cmds = append(cmds, m.startForward(sf.PlayID, sf.PlayName, sf.Spec)) + } + } + m.restore = nil + if len(cmds) > 0 { + return m, tea.Batch(cmds...) + } + } return m, nil case catalogMsg: @@ -693,9 +823,19 @@ func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if msg.region != "" { m.spawnRegions[msg.id] = msg.region m.region = msg.region + m.persistState() } return m, tea.Batch(m.loadPlays(), m.flash(okMark+" Started "+msg.name)) + case forwardStartedMsg: + m.forwards = append(m.forwards, forward{ + playID: msg.playID, playName: msg.playName, spec: msg.spec, + lport: msg.lport, rport: msg.rport, cancel: msg.cancel, + }) + m.persistState() + m.refreshRows() + return m, m.flash(okMark + " Forwarding " + msg.lport + " → " + msg.rport) + case tea.KeyMsg: return m.handleKey(msg) } @@ -763,6 +903,10 @@ func (m model) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m.handleConfirmKey(msg) case modalRegion: return m.handleRegionKey(msg) + case modalForward: + return m.handleForwardKey(msg) + case modalStopForward: + return m.handleStopForwardKey(msg) case modalHelp: // any key closes it m.modal = modalNone return m, nil @@ -883,6 +1027,7 @@ func (m model) handleThemesKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { return m, nil case "enter": m.modal = modalNone + uiConfig{Theme: m.theme}.save() return m, m.flash("Theme: " + m.theme) case "esc": m.themeIdx = m.themePrevIdx @@ -967,6 +1112,9 @@ func (m model) handleConfirmKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.confirmBtn = 0 m.confirmStage = 0 m.status = "Destroying..." + if m.stopForwards(id) > 0 { // drop now-dead forwards + m.persistState() + } return m, m.destroyPlay(id) } fallthrough @@ -1068,6 +1216,58 @@ func (m model) handleRegionKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { } } +// handleForwardKey drives the port-forward input: enter starts it, esc cancels. +func (m model) handleForwardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "esc": + m.modal = modalNone + m.input.Blur() + return m, nil + case "enter": + spec := strings.TrimSpace(m.input.Value()) + tgt := m.fwdTarget + m.modal = modalNone + m.input.Blur() + if spec == "" { + m.status = errMark + " Port required" + return m, nil + } + m.status = "Forwarding..." + return m, m.startForward(tgt.id, tgt.name, spec) + default: + var cmd tea.Cmd + m.input, cmd = m.input.Update(msg) + return m, cmd + } +} + +// handleStopForwardKey confirms stopping a lab's port-forwards. +func (m model) handleStopForwardKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { + switch msg.String() { + case "up", "down", "left", "right", "tab", "j", "k", "h", "l": + m.confirmBtn = 1 - m.confirmBtn + return m, nil + case "enter": + stop := m.confirmBtn == 1 + id := m.fwdTarget.id + m.modal = modalNone + m.confirmBtn = 0 + if !stop { + return m, nil + } + n := m.stopForwards(id) + m.persistState() + m.refreshRows() + return m, m.flash(fmt.Sprintf("%s Stopped %d forward(s)", okMark, n)) + case "esc": + m.modal = modalNone + m.confirmBtn = 0 + return m, nil + default: + return m, nil + } +} + // handlePromptKey drives the filter input (live-filters as you type). func (m model) handlePromptKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { @@ -1124,6 +1324,12 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { if p == nil { return m, nil } + // Either action drops the lab's tunnels; clear them so the PF column + // doesn't show stale ports. + if m.stopForwards(p.ID) > 0 { + m.persistState() + m.refreshRows() + } if p.StateIs(api.StateStopped) { m.status = "Starting..." return m, m.restartPlay(p.ID) @@ -1171,6 +1377,28 @@ func (m model) handlePlaysKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) { m.input.SetValue("") m.input.Placeholder = "ports e.g. 8080, 9090" return m, m.input.Focus() + case "F": // start a local background port-forward + if p == nil || !p.StateIs(api.StateRunning) { + m.status = errMark + " Select a running playground to forward a port" + return m, nil + } + m.modal = modalForward + m.fwdTarget = pending{id: p.ID, name: p.Playground.Name} + m.input.SetValue("") + m.input.Placeholder = "8080 or 3000:80" + return m, m.input.Focus() + case "ctrl+f": // stop this lab's background port-forwards (with confirmation) + if p == nil { + return m, nil + } + if len(m.fwdPortsByPlay()[p.ID]) == 0 { + m.status = errMark + " No forwards on this lab" + return m, nil + } + m.modal = modalStopForward + m.fwdTarget = pending{id: p.ID, name: p.Playground.Name} + m.confirmBtn = 0 // default to Cancel + return m, nil case "ctrl+d": // destroy the playground (with confirmation) if p == nil { return m, nil @@ -1298,12 +1526,13 @@ func (m *model) setSizes(w, h int) { inner := w - 2 // titledBox eats one column per side - pc := distribute(inner-6, []int{4, 1, 2, 2}, []int{12, 6, 8, 9}) // NAME REGION STATUS AGE + pc := distribute(inner-8, []int{4, 1, 2, 2, 2}, []int{12, 6, 8, 8, 12}) // NAME REGION STATUS TTL PF m.playsTable.SetColumns([]table.Column{ {Title: "NAME", Width: pc[0]}, {Title: "REGION", Width: pc[1]}, {Title: "STATUS", Width: pc[2]}, - {Title: "AGE", Width: pc[3]}, + {Title: "TTL", Width: pc[3]}, + {Title: "PF", Width: pc[4]}, }) m.playsTable.SetWidth(inner) @@ -1332,8 +1561,8 @@ func shortStatus(p *api.Play) string { return string(st) } -// playAge renders the elapsed/total lifetime, e.g. "12m/1h". For running labs -// total = elapsed + remaining (ExpiresIn); otherwise just the elapsed time. +// playAge renders the remaining lifetime for running labs, counting down +// (e.g. "7h50m"); for non-running labs it shows the elapsed time. func playAge(p *api.Play) string { created := parseTime(p.CreatedAt) if created.IsZero() { @@ -1343,9 +1572,21 @@ func playAge(p *api.Play) string { if elapsed < 0 { elapsed = 0 } - if p.StateIs(api.StateRunning) && p.ExpiresIn > 0 { - total := elapsed + time.Duration(p.ExpiresIn)*time.Millisecond - return fmtDur(elapsed) + "/" + fmtDur(total) + if p.StateIs(api.StateRunning) { + // Prefer the configured total (maxPlayTime, e.g. "480m"); fall back to + // elapsed + remaining when it's missing. + total, _ := time.ParseDuration(p.MaxPlayTime) + remaining := total - elapsed + if total <= 0 && p.ExpiresIn > 0 { + remaining = time.Duration(p.ExpiresIn) * time.Millisecond + total = elapsed + remaining + } + if total > 0 { + if remaining < 0 { + remaining = 0 + } + return fmtDur(remaining) + } } return fmtDur(elapsed) } @@ -1664,6 +1905,10 @@ func (m model) View() string { return m.themeView() case modalRegion: return m.regionView() + case modalForward: + return m.forwardView() + case modalStopForward: + return m.stopForwardView() case modalHelp: return m.helpView() default: // modalNone or modalFilter (filter renders as a footer bar) @@ -1918,6 +2163,24 @@ func (m model) exposePortView() string { })) } +func (m model) forwardView() string { + return m.overlayCentered(kDialog("Port-forward", []string{ + menuText.Render("Forward a local port (runs in the background)"), + "", + formField("Port:", m.input, true), + })) +} + +func (m model) stopForwardView() string { + ports := strings.Join(m.fwdPortsByPlay()[m.fwdTarget.id], ", ") + return m.overlayCentered(kDialog("Stop forwards", []string{ + menuText.Render("Stop port-forward(s) on " + m.fwdTarget.name + "?"), + infoVal.Render(ports), + "", + buttonRow([]string{"Cancel", "Stop"}, m.confirmBtn), + })) +} + func (m *model) selectedURL() string { if m.tab == tabCatalog { if pg := m.selectedPlayground(); pg != nil { @@ -2066,8 +2329,8 @@ func (m model) menuView() string { default: // tabPlays items = [][2]string{ {"enter", "SSH"}, {"i", "Info"}, {"w", "Share"}, - {"x", "Ports"}, {"P", "Persist"}, {":", "Filter"}, - {"r", "Refresh"}, {"tab", "Switch"}, {"?", "Shortcuts"}, + {"x", "Ports"}, {"F", "Forward"}, {"P", "Persist"}, + {":", "Filter"}, {"r", "Refresh"}, {"?", "Shortcuts"}, } } return menuColumns(items) @@ -2120,6 +2383,8 @@ func (m model) helpView() string { hdr("Export"), key("w", "share terminal"), key("x", "expose port(s)"), + key("F", "port-forward (bg)"), + key("ctrl+f", "stop forwards"), "", hdr("Exports tab"), key("enter", "copy url"), diff --git a/cmd/tui/tui_test.go b/cmd/tui/tui_test.go index f7b2a04..894a95a 100644 --- a/cmd/tui/tui_test.go +++ b/cmd/tui/tui_test.go @@ -41,9 +41,9 @@ func TestDelegateMovesCursor(t *testing.T) { m := model{tab: tabPlays, playsTable: table.New(table.WithFocused(true))} m.setSizes(80, 24) m.playsTable.SetRows([]table.Row{ - {"a", "", "", ""}, - {"b", "", "", ""}, - {"c", "", "", ""}, + {"a", "", "", "", ""}, + {"b", "", "", "", ""}, + {"c", "", "", "", ""}, }) if got := m.playsTable.Cursor(); got != 0 { @@ -660,21 +660,27 @@ func TestFmtDur(t *testing.T) { } } -// TestPlayAge guards that a running lab shows elapsed/total and a stopped one -// shows just the elapsed time (no slash). +// TestPlayAge guards that a running lab shows the remaining time (counting down, +// no slash) while a stopped one shows the elapsed time. func TestPlayAge(t *testing.T) { t.Parallel() running := playWithState("p", api.StateRunning) running.CreatedAt = time.Now().Add(-10 * time.Minute).Format(time.RFC3339) running.ExpiresIn = int((50 * time.Minute) / time.Millisecond) - if got := playAge(running); !strings.Contains(got, "/") { - t.Fatalf("running playAge = %q, want elapsed/total", got) + gotRunning := playAge(running) + if strings.Contains(gotRunning, "/") || gotRunning == "" { + t.Fatalf("running playAge = %q, want remaining only (no slash)", gotRunning) } stopped := playWithState("p", api.StateStopped) stopped.CreatedAt = time.Now().Add(-10 * time.Minute).Format(time.RFC3339) - if got := playAge(stopped); strings.Contains(got, "/") { - t.Fatalf("stopped playAge = %q, want elapsed only (no slash)", got) + gotStopped := playAge(stopped) + if strings.Contains(gotStopped, "/") { + t.Fatalf("stopped playAge = %q, want elapsed only", gotStopped) + } + // Remaining (~50m) must differ from elapsed (~10m). + if gotRunning == gotStopped { + t.Fatalf("running %q should differ from stopped %q", gotRunning, gotStopped) } } @@ -696,3 +702,95 @@ func TestSpawnRegionCached(t *testing.T) { t.Fatalf("REGION cell = %q, want %q (from spawn cache)", got, strings.ToUpper(api.RegionAP)) } } + +// TestForwardStartValidation guards the port-forward prompt: empty is rejected, +// a real spec kicks off the forward. +func TestForwardStartValidation(t *testing.T) { + t.Parallel() + for _, tt := range []struct{ in, want string }{ + {"8080", "Forwarding..."}, + {"3000:80", "Forwarding..."}, + {" ", errMark}, + {"", errMark}, + } { + m := newModel(testCLI()) + m.modal = modalForward + m.fwdTarget = pending{id: "p1", name: "pg"} + m.input.SetValue(tt.in) + out, _ := m.handleForwardKey(tea.KeyMsg{Type: tea.KeyEnter}) + if got := out.(model).status; !strings.HasPrefix(got, tt.want) { + t.Fatalf("in %q: status=%q want prefix %q", tt.in, got, tt.want) + } + } +} + +// TestStopForwards guards that ctrl+f's helper cancels and removes only the +// selected lab's forwards. +func TestStopForwards(t *testing.T) { + t.Parallel() + canceled := map[string]int{} + mk := func(id string) forward { + return forward{playID: id, cancel: func() { canceled[id]++ }} + } + m := newModel(testCLI()) + m.forwards = []forward{mk("p1"), mk("p1"), mk("p2")} + + if n := m.stopForwards("p1"); n != 2 { + t.Fatalf("stopForwards = %d, want 2", n) + } + if len(m.forwards) != 1 || m.forwards[0].playID != "p2" { + t.Fatalf("remaining forwards = %+v, want only p2", m.forwards) + } + if canceled["p1"] != 2 { + t.Fatalf("p1 cancel called %d times, want 2", canceled["p1"]) + } +} + +// TestForwardColumn guards that forwarded ports render in the PF column. +func TestForwardColumn(t *testing.T) { + t.Parallel() + _, rows := filterPlays( + []*api.Play{playWithState("p1", api.StateRunning)}, + "", nil, nil, map[string][]string{"p1": {"8080<->9090"}}, + ) + if len(rows) != 1 || rows[0][4] != "8080<->9090" { + t.Fatalf("PF cell = %q, want 8080<->9090", rows[0][4]) + } +} + +// TestStopForwardConfirm guards that ctrl+f asks first and Cancel leaves the +// forward running. +func TestStopForwardConfirm(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.plays = []*api.Play{playWithState("p1", api.StateRunning)} + m.forwards = []forward{{playID: "p1", lport: "8080", cancel: func() {}}} + m.refreshRows() + + out, _ := m.handlePlaysKey(tea.KeyMsg{Type: tea.KeyCtrlF}) + mo := out.(model) + if mo.modal != modalStopForward || mo.fwdTarget.id != "p1" { + t.Fatalf("ctrl+f: modal=%v target=%q, want modalStopForward/p1", mo.modal, mo.fwdTarget.id) + } + // Cancel (button 0) keeps the forward. + mo.confirmBtn = 0 + out2, _ := mo.handleStopForwardKey(tea.KeyMsg{Type: tea.KeyEnter}) + m2 := out2.(model) + if m2.modal != modalNone || len(m2.forwards) != 1 { + t.Fatalf("cancel: modal=%v forwards=%d, want modalNone/1", m2.modal, len(m2.forwards)) + } +} + +// TestForwardDuplicatePort guards that forwarding an already-bound local port is +// rejected before any network call. +func TestForwardDuplicatePort(t *testing.T) { + t.Parallel() + m := newModel(testCLI()) + m.forwards = []forward{{playID: "p1", lport: "1337", rport: "80"}} + + msg := m.startForward("p2", "pg", "1337:80")() + e, ok := msg.(errMsg) + if !ok || !strings.Contains(e.err.Error(), "already forwarded") { + t.Fatalf("msg = %#v, want errMsg about port already forwarded", msg) + } +}