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 }