aea21d713c
Parsea flags de -help de cada pipeline para mostrar formulario de argumentos antes de ejecutar. Filtra pipelines por tag 'launcher'. Corrige selección en historial delegando enter al list antes de leer item.
399 lines
9.4 KiB
Go
399 lines
9.4 KiB
Go
package views
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
ops "fn-registry/fn_operations"
|
|
"fn-registry/registry"
|
|
|
|
"github.com/charmbracelet/bubbles/textinput"
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
"github.com/lucasdataproyects/devfactory/tui"
|
|
)
|
|
|
|
type pipelinesState int
|
|
|
|
const (
|
|
pipelinesLoading pipelinesState = iota
|
|
pipelinesList
|
|
pipelinesArgs
|
|
pipelinesRunning
|
|
pipelinesOutput
|
|
)
|
|
|
|
type pipelinesLoadedMsg []registry.Function
|
|
type pipelineFinishedMsg RunResult
|
|
type pipelineFlagsMsg []PipelineFlag
|
|
|
|
// PipelinesModel lists and launches pipelines.
|
|
type PipelinesModel struct {
|
|
state pipelinesState
|
|
list tui.FilteredListModel
|
|
spinner tui.SpinnerModel
|
|
styles tui.Styles
|
|
pipelines []registry.Function
|
|
selectedFn *registry.Function
|
|
flags []PipelineFlag
|
|
inputs []textinput.Model
|
|
focusIdx int
|
|
output string
|
|
lastResult *RunResult
|
|
scrollOff int
|
|
err error
|
|
registryDB *registry.DB
|
|
opsDB *ops.DB
|
|
registryRoot string
|
|
}
|
|
|
|
// NewPipelinesModel creates a new pipelines view.
|
|
func NewPipelinesModel(styles tui.Styles, regDB *registry.DB, opsDB *ops.DB, root string) PipelinesModel {
|
|
return PipelinesModel{
|
|
state: pipelinesLoading,
|
|
list: tui.NewFilteredList(nil, "Filter pipelines..."),
|
|
spinner: tui.NewSpinner("Loading pipelines..."),
|
|
styles: styles,
|
|
registryDB: regDB,
|
|
opsDB: opsDB,
|
|
registryRoot: root,
|
|
}
|
|
}
|
|
|
|
func (m PipelinesModel) Init() tea.Cmd {
|
|
return tea.Batch(
|
|
m.spinner.Init(),
|
|
m.loadPipelines(),
|
|
)
|
|
}
|
|
|
|
func (m PipelinesModel) loadPipelines() tea.Cmd {
|
|
return func() tea.Msg {
|
|
fns, err := m.registryDB.SearchFunctions("", registry.KindPipeline, "", "", "")
|
|
if err != nil {
|
|
return pipelinesLoadedMsg(nil)
|
|
}
|
|
// Only show pipelines tagged with "launcher"
|
|
var launchable []registry.Function
|
|
for _, f := range fns {
|
|
for _, t := range f.Tags {
|
|
if t == "launcher" {
|
|
launchable = append(launchable, f)
|
|
break
|
|
}
|
|
}
|
|
}
|
|
return pipelinesLoadedMsg(launchable)
|
|
}
|
|
}
|
|
|
|
// buildInputs creates a textinput for each flag, pre-filled with defaults.
|
|
func (m *PipelinesModel) buildInputs() tea.Cmd {
|
|
m.inputs = make([]textinput.Model, len(m.flags))
|
|
for i, f := range m.flags {
|
|
ti := textinput.New()
|
|
ti.CharLimit = 256
|
|
ti.Width = 40
|
|
if f.Default != "" {
|
|
ti.SetValue(f.Default)
|
|
}
|
|
if f.Required {
|
|
ti.Placeholder = "(requerido)"
|
|
}
|
|
m.inputs[i] = ti
|
|
}
|
|
m.focusIdx = 0
|
|
if len(m.inputs) > 0 {
|
|
m.inputs[0].Focus()
|
|
return textinput.Blink
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (m *PipelinesModel) focusInput(idx int) tea.Cmd {
|
|
if idx < 0 || idx >= len(m.inputs) {
|
|
return nil
|
|
}
|
|
for i := range m.inputs {
|
|
m.inputs[i].Blur()
|
|
}
|
|
m.focusIdx = idx
|
|
m.inputs[idx].Focus()
|
|
return textinput.Blink
|
|
}
|
|
|
|
// collectArgs builds CLI args from the form inputs.
|
|
func (m PipelinesModel) collectArgs() []string {
|
|
var args []string
|
|
for i, f := range m.flags {
|
|
val := strings.TrimSpace(m.inputs[i].Value())
|
|
if val != "" {
|
|
args = append(args, "--"+f.Name, val)
|
|
}
|
|
}
|
|
return args
|
|
}
|
|
|
|
func (m PipelinesModel) Update(msg tea.Msg) (PipelinesModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case pipelinesLoadedMsg:
|
|
m.pipelines = []registry.Function(msg)
|
|
items := make([]tui.ListItem, len(m.pipelines))
|
|
for i, p := range m.pipelines {
|
|
items[i] = tui.ListItem{
|
|
Title: p.Name,
|
|
Description: fmt.Sprintf("%s — %s", p.Domain, truncate(p.Description, 60)),
|
|
Value: p,
|
|
}
|
|
}
|
|
m.list.SetItems(items)
|
|
m.state = pipelinesList
|
|
return m, nil
|
|
|
|
case pipelineFlagsMsg:
|
|
m.flags = []PipelineFlag(msg)
|
|
cmd := m.buildInputs()
|
|
return m, cmd
|
|
|
|
case pipelineFinishedMsg:
|
|
result := RunResult(msg)
|
|
m.lastResult = &result
|
|
var sb strings.Builder
|
|
if result.Status == ops.ExecSuccess {
|
|
sb.WriteString("[OK] ")
|
|
} else {
|
|
sb.WriteString("[FAIL] ")
|
|
}
|
|
fmt.Fprintf(&sb, "Pipeline: %s\n", result.PipelineID)
|
|
fmt.Fprintf(&sb, "Execution: %s\n", result.ExecID)
|
|
fmt.Fprintf(&sb, "Duration: %dms\n", result.DurationMs)
|
|
sb.WriteString("\n--- stdout ---\n")
|
|
if result.Stdout != "" {
|
|
sb.WriteString(result.Stdout)
|
|
} else {
|
|
sb.WriteString("(empty)")
|
|
}
|
|
if result.Stderr != "" {
|
|
sb.WriteString("\n--- stderr ---\n")
|
|
sb.WriteString(result.Stderr)
|
|
}
|
|
if result.Err != nil {
|
|
fmt.Fprintf(&sb, "\n--- error ---\n%v", result.Err)
|
|
}
|
|
m.output = sb.String()
|
|
m.state = pipelinesOutput
|
|
m.scrollOff = 0
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch m.state {
|
|
case pipelinesList:
|
|
switch msg.String() {
|
|
case "r":
|
|
m.state = pipelinesLoading
|
|
m.spinner = tui.NewSpinner("Loading pipelines...")
|
|
return m, tea.Batch(m.spinner.Init(), m.loadPipelines())
|
|
case "enter":
|
|
updated, _ := m.list.Update(msg)
|
|
m.list = updated.(tui.FilteredListModel)
|
|
if item := m.list.SelectedItem(); item != nil {
|
|
fn := item.Value.(registry.Function)
|
|
m.selectedFn = &fn
|
|
m.flags = nil
|
|
m.inputs = nil
|
|
m.state = pipelinesArgs
|
|
root := m.registryRoot
|
|
fnCopy := fn
|
|
return m, func() tea.Msg {
|
|
return pipelineFlagsMsg(GetPipelineFlags(&fnCopy, root))
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
case pipelinesArgs:
|
|
switch msg.String() {
|
|
case "tab", "down":
|
|
cmd := m.focusInput((m.focusIdx + 1) % max(len(m.inputs), 1))
|
|
return m, cmd
|
|
case "shift+tab", "up":
|
|
idx := m.focusIdx - 1
|
|
if idx < 0 {
|
|
idx = max(len(m.inputs)-1, 0)
|
|
}
|
|
cmd := m.focusInput(idx)
|
|
return m, cmd
|
|
case "ctrl+enter", "ctrl+s":
|
|
args := m.collectArgs()
|
|
m.state = pipelinesRunning
|
|
m.spinner = tui.NewSpinner(fmt.Sprintf("Running %s...", m.selectedFn.Name))
|
|
return m, tea.Batch(m.spinner.Init(), m.runPipelineCmd(m.selectedFn, args))
|
|
case "esc":
|
|
m.state = pipelinesList
|
|
return m, nil
|
|
}
|
|
case pipelinesOutput:
|
|
switch msg.String() {
|
|
case "j", "down":
|
|
m.scrollOff++
|
|
case "k", "up":
|
|
if m.scrollOff > 0 {
|
|
m.scrollOff--
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
|
|
// Delegate to sub-components
|
|
var cmd tea.Cmd
|
|
switch m.state {
|
|
case pipelinesLoading, pipelinesRunning:
|
|
var spinnerModel tea.Model
|
|
spinnerModel, cmd = m.spinner.Update(msg)
|
|
m.spinner = spinnerModel.(tui.SpinnerModel)
|
|
case pipelinesList:
|
|
var listModel tea.Model
|
|
listModel, cmd = m.list.Update(msg)
|
|
m.list = listModel.(tui.FilteredListModel)
|
|
case pipelinesArgs:
|
|
if m.focusIdx >= 0 && m.focusIdx < len(m.inputs) {
|
|
m.inputs[m.focusIdx], cmd = m.inputs[m.focusIdx].Update(msg)
|
|
}
|
|
}
|
|
return m, cmd
|
|
}
|
|
|
|
func (m PipelinesModel) runPipelineCmd(fn *registry.Function, args []string) tea.Cmd {
|
|
regRoot := m.registryRoot
|
|
opsDB := m.opsDB
|
|
fnCopy := *fn
|
|
return func() tea.Msg {
|
|
result := RunPipeline(&fnCopy, regRoot, opsDB, args)
|
|
return pipelineFinishedMsg(result)
|
|
}
|
|
}
|
|
|
|
// HandleBack retrocede un nivel. Retorna true si ya en estado base.
|
|
func (m *PipelinesModel) HandleBack() bool {
|
|
switch m.state {
|
|
case pipelinesArgs:
|
|
m.state = pipelinesList
|
|
return false
|
|
case pipelinesOutput:
|
|
m.state = pipelinesList
|
|
return false
|
|
default:
|
|
return true
|
|
}
|
|
}
|
|
|
|
func (m PipelinesModel) View() string {
|
|
switch m.state {
|
|
case pipelinesLoading:
|
|
return m.spinner.View()
|
|
case pipelinesList:
|
|
if len(m.pipelines) == 0 {
|
|
return m.styles.Muted.Render("No pipelines found. Press 'r' to refresh.")
|
|
}
|
|
help := m.styles.Muted.Render(" Enter: launch │ r: refresh │ /: filter")
|
|
return m.list.View() + "\n" + help
|
|
case pipelinesArgs:
|
|
return m.renderArgsForm()
|
|
case pipelinesRunning:
|
|
return m.spinner.View()
|
|
case pipelinesOutput:
|
|
return m.renderOutput()
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func (m PipelinesModel) renderArgsForm() string {
|
|
header := m.styles.Header.Render(m.selectedFn.Name)
|
|
|
|
var parts []string
|
|
parts = append(parts, header, "")
|
|
|
|
if len(m.flags) == 0 {
|
|
parts = append(parts, m.styles.Muted.Render(" Loading flags..."))
|
|
} else if len(m.inputs) == 0 {
|
|
parts = append(parts, m.styles.Muted.Render(" No flags available. Ctrl+S to run."))
|
|
} else {
|
|
for i, f := range m.flags {
|
|
marker := " "
|
|
if f.Required {
|
|
marker = m.styles.Error.Render("* ")
|
|
}
|
|
|
|
name := fmt.Sprintf("--%-16s", f.Name)
|
|
cursor := " "
|
|
if i == m.focusIdx {
|
|
cursor = m.styles.Info.Render("> ")
|
|
}
|
|
|
|
label := fmt.Sprintf("%s%s%s", cursor, marker, m.styles.Label.Render(name))
|
|
input := m.inputs[i].View()
|
|
|
|
desc := f.Desc
|
|
if f.Default != "" {
|
|
desc += m.styles.Muted.Render(fmt.Sprintf(" (default: %s)", f.Default))
|
|
}
|
|
|
|
parts = append(parts, label+input)
|
|
parts = append(parts, " "+m.styles.Muted.Render(desc))
|
|
}
|
|
}
|
|
|
|
parts = append(parts, "")
|
|
parts = append(parts, m.styles.Muted.Render(" ↑/↓: navigate │ Ctrl+S: run │ Esc: cancel"))
|
|
|
|
return lipgloss.JoinVertical(lipgloss.Left, parts...)
|
|
}
|
|
|
|
func (m PipelinesModel) renderOutput() string {
|
|
lines := splitLines(m.output)
|
|
maxLines := 20
|
|
if m.scrollOff >= len(lines) {
|
|
m.scrollOff = max(0, len(lines)-1)
|
|
}
|
|
end := min(m.scrollOff+maxLines, len(lines))
|
|
visible := lines[m.scrollOff:end]
|
|
|
|
header := m.styles.Header.Render("Pipeline Output")
|
|
content := lipgloss.JoinVertical(lipgloss.Left, visible...)
|
|
help := m.styles.Muted.Render(" j/k: scroll │ Esc: back")
|
|
|
|
return header + "\n" + content + "\n" + help
|
|
}
|
|
|
|
func splitLines(s string) []string {
|
|
if s == "" {
|
|
return []string{"(empty)"}
|
|
}
|
|
lines := strings.Split(s, "\n")
|
|
if len(lines) == 0 {
|
|
return []string{"(empty)"}
|
|
}
|
|
return lines
|
|
}
|
|
|
|
func truncate(s string, n int) string {
|
|
if len(s) <= n {
|
|
return s
|
|
}
|
|
return s[:n-3] + "..."
|
|
}
|
|
|
|
func max(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func min(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|