c36aa18c67
Nuevas skills para crear TUIs, inicializar frontends React y módulos Go. Incluye binario parallel-executor y utilidades de soporte. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
1482 lines
37 KiB
Bash
Executable File
1482 lines
37 KiB
Bash
Executable File
#!/usr/bin/env bash
|
|
set -euo pipefail
|
|
|
|
# =============================================================================
|
|
# setup-create-tui.sh — Scaffoldea aplicación TUI en Go con DevFactory
|
|
# Genera un TUI completo para gestionar scripts, comandos, Makefile y builds
|
|
# =============================================================================
|
|
|
|
# --- Colores ---
|
|
RED='\033[0;31m'
|
|
GREEN='\033[0;32m'
|
|
YELLOW='\033[1;33m'
|
|
BLUE='\033[0;34m'
|
|
CYAN='\033[0;36m'
|
|
NC='\033[0m'
|
|
|
|
log_info() { echo -e "${BLUE}[INFO]${NC} $1"; }
|
|
log_ok() { echo -e "${GREEN}[OK]${NC} $1"; }
|
|
log_warn() { echo -e "${YELLOW}[WARN]${NC} $1"; }
|
|
log_error() { echo -e "${RED}[ERROR]${NC} $1"; }
|
|
log_step() { echo -e "${CYAN}[STEP]${NC} $1"; }
|
|
|
|
# --- Parámetros ---
|
|
MODULE_NAME="${1:-}"
|
|
TARGET_PATH="${2:-.}"
|
|
DEVFACTORY_PATH="$HOME/.local_agentes/backend"
|
|
DEVFACTORY_MODULE="github.com/lucasdataproyects/devfactory"
|
|
|
|
# --- Validar nombre ---
|
|
if [[ -z "$MODULE_NAME" ]]; then
|
|
log_error "Uso: setup-create-tui.sh <nombre> [path]"
|
|
echo "STATUS: ERROR"
|
|
exit 1
|
|
fi
|
|
|
|
# Normalizar nombre a kebab-case
|
|
MODULE_NAME=$(echo "$MODULE_NAME" | tr '[:upper:]' '[:lower:]' | sed 's/[^a-z0-9-]/-/g' | sed 's/--*/-/g' | sed 's/^-\|-$//g')
|
|
PROJECT_DIR="$TARGET_PATH/$MODULE_NAME"
|
|
GO_MODULE="github.com/lucasdataproyects/$MODULE_NAME"
|
|
|
|
# --- Check estado existente ---
|
|
if [[ -f "$PROJECT_DIR/go.mod" ]]; then
|
|
log_warn "El proyecto TUI $MODULE_NAME ya existe en $PROJECT_DIR"
|
|
echo "STATUS: CONFIGURED"
|
|
exit 0
|
|
fi
|
|
|
|
# --- Verificar dependencias ---
|
|
log_step "Verificando dependencias..."
|
|
|
|
if ! command -v go &>/dev/null; then
|
|
log_error "Go no está instalado. Instala Go 1.22+"
|
|
echo "STATUS: ERROR"
|
|
exit 1
|
|
fi
|
|
|
|
GO_VERSION=$(go version | grep -oP '\d+\.\d+' | head -1)
|
|
log_ok "Go $GO_VERSION encontrado"
|
|
|
|
if [[ ! -d "$DEVFACTORY_PATH" ]]; then
|
|
log_error "DevFactory no encontrado en $DEVFACTORY_PATH — es necesario para los componentes TUI"
|
|
echo "STATUS: ERROR"
|
|
exit 1
|
|
fi
|
|
|
|
log_ok "DevFactory encontrado en $DEVFACTORY_PATH"
|
|
|
|
# --- Crear estructura ---
|
|
log_step "Creando estructura del proyecto TUI '$MODULE_NAME'..."
|
|
|
|
mkdir -p "$PROJECT_DIR"/{app,views,config}
|
|
|
|
# =============================================================================
|
|
# go.mod
|
|
# =============================================================================
|
|
log_step "Generando go.mod..."
|
|
cat > "$PROJECT_DIR/go.mod" << EOF
|
|
module $GO_MODULE
|
|
|
|
go 1.22.2
|
|
|
|
require $DEVFACTORY_MODULE v0.0.0
|
|
|
|
replace $DEVFACTORY_MODULE => $DEVFACTORY_PATH
|
|
EOF
|
|
|
|
# =============================================================================
|
|
# go.work
|
|
# =============================================================================
|
|
log_step "Generando go.work con DevFactory local..."
|
|
cat > "$PROJECT_DIR/go.work" << EOF
|
|
go 1.22.2
|
|
|
|
use (
|
|
.
|
|
$DEVFACTORY_PATH
|
|
)
|
|
EOF
|
|
log_ok "go.work enlazado a DevFactory"
|
|
|
|
# =============================================================================
|
|
# config/config.go
|
|
# =============================================================================
|
|
log_step "Generando config/config.go..."
|
|
cat > "$PROJECT_DIR/config/config.go" << 'GOEOF'
|
|
package config
|
|
|
|
import (
|
|
"path/filepath"
|
|
)
|
|
|
|
// CommandEntry representa un comando frecuente configurable.
|
|
type CommandEntry struct {
|
|
Name string
|
|
Command string
|
|
Description string
|
|
}
|
|
|
|
// BuildConfig representa una configuración de build.
|
|
type BuildConfig struct {
|
|
Name string
|
|
Command string
|
|
Description string
|
|
}
|
|
|
|
// Config contiene la configuración del TUI.
|
|
type Config struct {
|
|
RootDir string
|
|
Commands []CommandEntry
|
|
BuildConfigs []BuildConfig
|
|
}
|
|
|
|
// New crea una configuración con defaults sensatos.
|
|
func New(dir string) Config {
|
|
absDir, err := filepath.Abs(dir)
|
|
if err != nil {
|
|
absDir = dir
|
|
}
|
|
return Config{
|
|
RootDir: absDir,
|
|
Commands: []CommandEntry{
|
|
{Name: "Go Build", Command: "go build ./...", Description: "Compilar todos los paquetes"},
|
|
{Name: "Go Test", Command: "go test ./... -v", Description: "Ejecutar todos los tests"},
|
|
{Name: "Go Vet", Command: "go vet ./...", Description: "Análisis estático del código"},
|
|
{Name: "Git Status", Command: "git status", Description: "Estado del repositorio"},
|
|
{Name: "Git Log", Command: "git log --oneline -10", Description: "Últimos 10 commits"},
|
|
},
|
|
BuildConfigs: []BuildConfig{
|
|
{Name: "Debug", Command: "go build -gcflags='-N -l' -o build/debug .", Description: "Build con símbolos de debug"},
|
|
{Name: "Release", Command: "go build -trimpath -ldflags='-s -w' -o build/release .", Description: "Build optimizado para producción"},
|
|
{Name: "Linux AMD64", Command: "GOOS=linux GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o build/app-linux-amd64 .", Description: "Cross-compile Linux 64-bit"},
|
|
{Name: "Windows AMD64", Command: "GOOS=windows GOARCH=amd64 go build -trimpath -ldflags='-s -w' -o build/app-windows-amd64.exe .", Description: "Cross-compile Windows 64-bit"},
|
|
},
|
|
}
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# views/keys.go — Constantes de navegación compartidas
|
|
# =============================================================================
|
|
log_step "Generando views/keys.go..."
|
|
cat > "$PROJECT_DIR/views/keys.go" << 'GOEOF'
|
|
package views
|
|
|
|
// Constantes de teclas para navegación.
|
|
const (
|
|
KeyQuit = "ctrl+c"
|
|
KeyEsc = "esc"
|
|
KeyBack = "0"
|
|
)
|
|
|
|
// IsBack retorna true si la tecla es de retroceso (Esc o 0).
|
|
func IsBack(key string) bool {
|
|
return key == KeyEsc || key == KeyBack
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# views/exec.go — Tipos de mensaje y comandos async
|
|
# =============================================================================
|
|
log_step "Generando views/exec.go..."
|
|
cat > "$PROJECT_DIR/views/exec.go" << 'GOEOF'
|
|
package views
|
|
|
|
import (
|
|
"os"
|
|
"os/exec"
|
|
"path/filepath"
|
|
"regexp"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
)
|
|
|
|
// ExecResultMsg se envía cuando un comando termina de ejecutarse.
|
|
type ExecResultMsg struct {
|
|
Output string
|
|
Error string
|
|
ExitCode int
|
|
Label string
|
|
}
|
|
|
|
// ScriptEntry representa un script descubierto.
|
|
type ScriptEntry struct {
|
|
Name string
|
|
Path string
|
|
Description string
|
|
}
|
|
|
|
// DiscoverScriptsMsg contiene los scripts descubiertos.
|
|
type DiscoverScriptsMsg struct {
|
|
Scripts []ScriptEntry
|
|
}
|
|
|
|
// MakeTarget representa un target de Makefile.
|
|
type MakeTarget struct {
|
|
Name string
|
|
Description string
|
|
}
|
|
|
|
// DiscoverMakeTargetsMsg contiene los targets descubiertos.
|
|
type DiscoverMakeTargetsMsg struct {
|
|
Targets []MakeTarget
|
|
}
|
|
|
|
// ExecCommand ejecuta un comando shell de forma asíncrona.
|
|
func ExecCommand(label, command, workDir string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
cmd := exec.Command("bash", "-c", command)
|
|
cmd.Dir = workDir
|
|
out, err := cmd.CombinedOutput()
|
|
exitCode := 0
|
|
errMsg := ""
|
|
if err != nil {
|
|
errMsg = err.Error()
|
|
if exitErr, ok := err.(*exec.ExitError); ok {
|
|
exitCode = exitErr.ExitCode()
|
|
} else {
|
|
exitCode = 1
|
|
}
|
|
}
|
|
return ExecResultMsg{
|
|
Output: string(out),
|
|
Error: errMsg,
|
|
ExitCode: exitCode,
|
|
Label: label,
|
|
}
|
|
}
|
|
}
|
|
|
|
// DiscoverScripts busca archivos .sh en el directorio raíz.
|
|
func DiscoverScripts(rootDir string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
var scripts []ScriptEntry
|
|
skipDirs := map[string]bool{
|
|
".git": true, "node_modules": true, "vendor": true,
|
|
"build": true, ".venv": true, "__pycache__": true,
|
|
}
|
|
_ = filepath.Walk(rootDir, func(path string, info os.FileInfo, err error) error {
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
if info.IsDir() {
|
|
if skipDirs[info.Name()] {
|
|
return filepath.SkipDir
|
|
}
|
|
return nil
|
|
}
|
|
if strings.HasSuffix(info.Name(), ".sh") {
|
|
relPath, _ := filepath.Rel(rootDir, path)
|
|
desc := extractScriptDescription(path)
|
|
scripts = append(scripts, ScriptEntry{
|
|
Name: relPath,
|
|
Path: path,
|
|
Description: desc,
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
return DiscoverScriptsMsg{Scripts: scripts}
|
|
}
|
|
}
|
|
|
|
func extractScriptDescription(path string) string {
|
|
data, err := os.ReadFile(path)
|
|
if err != nil {
|
|
return ""
|
|
}
|
|
lines := strings.Split(string(data), "\n")
|
|
limit := len(lines)
|
|
if limit > 10 {
|
|
limit = 10
|
|
}
|
|
for _, line := range lines[:limit] {
|
|
trimmed := strings.TrimSpace(line)
|
|
if strings.HasPrefix(trimmed, "# ") && !strings.HasPrefix(trimmed, "#!") {
|
|
return strings.TrimPrefix(trimmed, "# ")
|
|
}
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// DiscoverMakeTargets parsea el Makefile buscando targets.
|
|
func DiscoverMakeTargets(rootDir string) tea.Cmd {
|
|
return func() tea.Msg {
|
|
makefilePath := filepath.Join(rootDir, "Makefile")
|
|
data, err := os.ReadFile(makefilePath)
|
|
if err != nil {
|
|
return DiscoverMakeTargetsMsg{Targets: nil}
|
|
}
|
|
|
|
var targets []MakeTarget
|
|
lines := strings.Split(string(data), "\n")
|
|
targetRegex := regexp.MustCompile(`^([a-zA-Z_][a-zA-Z0-9_-]*)\s*:`)
|
|
var lastComment string
|
|
|
|
for _, line := range lines {
|
|
if strings.HasPrefix(line, "## ") {
|
|
lastComment = strings.TrimPrefix(line, "## ")
|
|
} else if match := targetRegex.FindStringSubmatch(line); match != nil {
|
|
target := match[1]
|
|
if target != ".PHONY" {
|
|
desc := lastComment
|
|
lastComment = ""
|
|
parts := strings.SplitN(desc, ":", 2)
|
|
if len(parts) == 2 {
|
|
desc = strings.TrimSpace(parts[1])
|
|
}
|
|
targets = append(targets, MakeTarget{
|
|
Name: target,
|
|
Description: desc,
|
|
})
|
|
}
|
|
} else {
|
|
lastComment = ""
|
|
}
|
|
}
|
|
return DiscoverMakeTargetsMsg{Targets: targets}
|
|
}
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# views/menu.go
|
|
# =============================================================================
|
|
log_step "Generando views/menu.go..."
|
|
cat > "$PROJECT_DIR/views/menu.go" << GOEOF
|
|
package views
|
|
|
|
import (
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"$DEVFACTORY_MODULE/tui"
|
|
)
|
|
|
|
// View identifica la vista activa.
|
|
type View int
|
|
|
|
const (
|
|
ViewMenu View = iota
|
|
ViewScripts
|
|
ViewCommands
|
|
ViewMakefiles
|
|
ViewBuilds
|
|
)
|
|
|
|
// MenuModel es el menú principal del TUI.
|
|
type MenuModel struct {
|
|
list tui.ListModel
|
|
done bool
|
|
}
|
|
|
|
// NewMenu crea el menú principal con las 4 opciones.
|
|
func NewMenu() MenuModel {
|
|
items := []tui.ListItem{
|
|
{Title: "Scripts", Description: "Buscar y ejecutar scripts .sh del repositorio", Value: ViewScripts},
|
|
{Title: "Comandos", Description: "Ejecutar comandos frecuentes configurados", Value: ViewCommands},
|
|
{Title: "Make", Description: "Ejecutar targets del Makefile", Value: ViewMakefiles},
|
|
{Title: "Builds", Description: "Gestionar configuraciones de build", Value: ViewBuilds},
|
|
}
|
|
list := tui.NewList(items)
|
|
list = list.WithHeight(10).WithWidth(60)
|
|
return MenuModel{list: list}
|
|
}
|
|
|
|
// SelectedView retorna la vista seleccionada.
|
|
func (m MenuModel) SelectedView() View {
|
|
item := m.list.SelectedItem()
|
|
if item == nil {
|
|
return ViewMenu
|
|
}
|
|
if v, ok := item.Value.(View); ok {
|
|
return v
|
|
}
|
|
return ViewMenu
|
|
}
|
|
|
|
// Init implementa tea.Model.
|
|
func (m MenuModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implementa tea.Model.
|
|
func (m MenuModel) Update(msg tea.Msg) (MenuModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.KeyMsg:
|
|
switch msg.String() {
|
|
case "enter", " ":
|
|
m.list.Select()
|
|
m.done = true
|
|
return m, nil
|
|
}
|
|
}
|
|
updated, cmd := m.list.Update(msg)
|
|
m.list = updated.(tui.ListModel)
|
|
return m, cmd
|
|
}
|
|
|
|
// View implementa tea.Model.
|
|
func (m MenuModel) View() string {
|
|
titleStyle := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("6")).
|
|
MarginBottom(1)
|
|
|
|
title := titleStyle.Render("Menú Principal")
|
|
return title + "\n" + m.list.View()
|
|
}
|
|
|
|
// IsDone retorna true si el usuario seleccionó una opción.
|
|
func (m MenuModel) IsDone() bool {
|
|
return m.done
|
|
}
|
|
|
|
// Reset limpia el estado de selección.
|
|
func (m *MenuModel) Reset() {
|
|
m.done = false
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# views/scripts.go
|
|
# =============================================================================
|
|
log_step "Generando views/scripts.go..."
|
|
cat > "$PROJECT_DIR/views/scripts.go" << GOEOF
|
|
package views
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"$DEVFACTORY_MODULE/tui"
|
|
)
|
|
|
|
type scriptsState int
|
|
|
|
const (
|
|
scriptsLoading scriptsState = iota
|
|
scriptsList
|
|
scriptsRunning
|
|
scriptsOutput
|
|
)
|
|
|
|
// ScriptsModel gestiona la vista de scripts.
|
|
type ScriptsModel struct {
|
|
state scriptsState
|
|
list tui.FilteredListModel
|
|
spinner tui.SpinnerModel
|
|
rootDir string
|
|
output string
|
|
exitCode int
|
|
runLabel string
|
|
scrollOff int
|
|
viewHeight int
|
|
}
|
|
|
|
// NewScripts crea la vista de scripts.
|
|
func NewScripts(rootDir string) ScriptsModel {
|
|
list := tui.NewFilteredList(nil, "Buscar script...")
|
|
list.ListModel = list.ListModel.WithHeight(15).WithWidth(60)
|
|
return ScriptsModel{
|
|
state: scriptsLoading,
|
|
list: list,
|
|
spinner: tui.NewSpinner("Buscando scripts..."),
|
|
rootDir: rootDir,
|
|
viewHeight: 20,
|
|
}
|
|
}
|
|
|
|
// Init lanza el descubrimiento de scripts.
|
|
func (m ScriptsModel) Init() tea.Cmd {
|
|
return tea.Batch(m.spinner.Init(), DiscoverScripts(m.rootDir))
|
|
}
|
|
|
|
// Update implementa tea.Model.
|
|
func (m ScriptsModel) Update(msg tea.Msg) (ScriptsModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case DiscoverScriptsMsg:
|
|
items := make([]tui.ListItem, len(msg.Scripts))
|
|
for i, s := range msg.Scripts {
|
|
items[i] = tui.ListItem{
|
|
Title: s.Name,
|
|
Description: s.Description,
|
|
Value: s.Path,
|
|
}
|
|
}
|
|
m.list.ListModel.SetItems(items)
|
|
m.list = tui.NewFilteredList(items, "Buscar script...")
|
|
m.list.ListModel = m.list.ListModel.WithHeight(15).WithWidth(60)
|
|
m.state = scriptsList
|
|
return m, nil
|
|
|
|
case ExecResultMsg:
|
|
m.output = msg.Output
|
|
if msg.Error != "" && msg.Output == "" {
|
|
m.output = msg.Error
|
|
}
|
|
m.exitCode = msg.ExitCode
|
|
m.runLabel = msg.Label
|
|
m.state = scriptsOutput
|
|
m.scrollOff = 0
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch m.state {
|
|
case scriptsList:
|
|
switch msg.String() {
|
|
case "enter":
|
|
item := m.list.SelectedItem()
|
|
if item != nil {
|
|
path := item.Value.(string)
|
|
m.state = scriptsRunning
|
|
m.spinner.SetMessage("Ejecutando " + item.Title + "...")
|
|
return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, "bash "+path, m.rootDir))
|
|
}
|
|
}
|
|
case scriptsOutput:
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.scrollOff > 0 {
|
|
m.scrollOff--
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
lines := strings.Split(m.output, "\n")
|
|
if m.scrollOff < len(lines)-m.viewHeight {
|
|
m.scrollOff++
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
m.viewHeight = msg.Height - 6
|
|
}
|
|
|
|
if m.state == scriptsLoading || m.state == scriptsRunning {
|
|
updated, cmd := m.spinner.Update(msg)
|
|
m.spinner = updated.(tui.SpinnerModel)
|
|
return m, cmd
|
|
}
|
|
|
|
if m.state == scriptsList {
|
|
updated, cmd := m.list.Update(msg)
|
|
m.list = updated.(tui.FilteredListModel)
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// View implementa tea.Model.
|
|
func (m ScriptsModel) View() string {
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
|
|
|
switch m.state {
|
|
case scriptsLoading:
|
|
return titleStyle.Render("Scripts") + "\n\n" + m.spinner.View()
|
|
case scriptsList:
|
|
return titleStyle.Render("Scripts") + "\n\n" + m.list.View()
|
|
case scriptsRunning:
|
|
return titleStyle.Render("Scripts") + "\n\n" + m.spinner.View()
|
|
case scriptsOutput:
|
|
header := titleStyle.Render("Scripts > " + m.runLabel)
|
|
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
|
|
if m.exitCode != 0 {
|
|
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
|
}
|
|
status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode))
|
|
|
|
lines := strings.Split(m.output, "\n")
|
|
end := m.scrollOff + m.viewHeight
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
visible := lines
|
|
if m.scrollOff < len(lines) {
|
|
visible = lines[m.scrollOff:end]
|
|
}
|
|
content := strings.Join(visible, "\n")
|
|
|
|
return header + " " + status + "\n\n" + content
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HandleBack retorna true si la tecla es back y la vista puede retroceder.
|
|
func (m *ScriptsModel) HandleBack() bool {
|
|
if m.state == scriptsOutput {
|
|
m.state = scriptsList
|
|
m.output = ""
|
|
m.scrollOff = 0
|
|
return false
|
|
}
|
|
if m.state == scriptsList {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# views/commands.go
|
|
# =============================================================================
|
|
log_step "Generando views/commands.go..."
|
|
cat > "$PROJECT_DIR/views/commands.go" << GOEOF
|
|
package views
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"$GO_MODULE/config"
|
|
"$DEVFACTORY_MODULE/tui"
|
|
)
|
|
|
|
type commandsState int
|
|
|
|
const (
|
|
commandsList commandsState = iota
|
|
commandsRunning
|
|
commandsOutput
|
|
)
|
|
|
|
// CommandsModel gestiona la vista de comandos frecuentes.
|
|
type CommandsModel struct {
|
|
state commandsState
|
|
list tui.ListModel
|
|
spinner tui.SpinnerModel
|
|
rootDir string
|
|
output string
|
|
exitCode int
|
|
runLabel string
|
|
scrollOff int
|
|
viewHeight int
|
|
}
|
|
|
|
// NewCommands crea la vista de comandos frecuentes.
|
|
func NewCommands(cfg config.Config) CommandsModel {
|
|
items := make([]tui.ListItem, len(cfg.Commands))
|
|
for i, c := range cfg.Commands {
|
|
items[i] = tui.ListItem{
|
|
Title: c.Name,
|
|
Description: c.Description + " — " + c.Command,
|
|
Value: c.Command,
|
|
}
|
|
}
|
|
list := tui.NewList(items)
|
|
list = list.WithHeight(12).WithWidth(60)
|
|
return CommandsModel{
|
|
state: commandsList,
|
|
list: list,
|
|
spinner: tui.NewSpinner("Ejecutando..."),
|
|
rootDir: cfg.RootDir,
|
|
viewHeight: 20,
|
|
}
|
|
}
|
|
|
|
// Init implementa tea.Model.
|
|
func (m CommandsModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implementa tea.Model.
|
|
func (m CommandsModel) Update(msg tea.Msg) (CommandsModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case ExecResultMsg:
|
|
m.output = msg.Output
|
|
if msg.Error != "" && msg.Output == "" {
|
|
m.output = msg.Error
|
|
}
|
|
m.exitCode = msg.ExitCode
|
|
m.runLabel = msg.Label
|
|
m.state = commandsOutput
|
|
m.scrollOff = 0
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch m.state {
|
|
case commandsList:
|
|
if msg.String() == "enter" {
|
|
item := m.list.SelectedItem()
|
|
if item != nil {
|
|
cmd := item.Value.(string)
|
|
m.state = commandsRunning
|
|
m.spinner.SetMessage("Ejecutando " + item.Title + "...")
|
|
return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, cmd, m.rootDir))
|
|
}
|
|
}
|
|
case commandsOutput:
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.scrollOff > 0 {
|
|
m.scrollOff--
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
lines := strings.Split(m.output, "\n")
|
|
if m.scrollOff < len(lines)-m.viewHeight {
|
|
m.scrollOff++
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
m.viewHeight = msg.Height - 6
|
|
}
|
|
|
|
if m.state == commandsRunning {
|
|
updated, cmd := m.spinner.Update(msg)
|
|
m.spinner = updated.(tui.SpinnerModel)
|
|
return m, cmd
|
|
}
|
|
|
|
if m.state == commandsList {
|
|
updated, cmd := m.list.Update(msg)
|
|
m.list = updated.(tui.ListModel)
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// View implementa tea.Model.
|
|
func (m CommandsModel) View() string {
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
|
|
|
switch m.state {
|
|
case commandsList:
|
|
return titleStyle.Render("Comandos") + "\n\n" + m.list.View()
|
|
case commandsRunning:
|
|
return titleStyle.Render("Comandos") + "\n\n" + m.spinner.View()
|
|
case commandsOutput:
|
|
header := titleStyle.Render("Comandos > " + m.runLabel)
|
|
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
|
|
if m.exitCode != 0 {
|
|
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
|
}
|
|
status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode))
|
|
|
|
lines := strings.Split(m.output, "\n")
|
|
end := m.scrollOff + m.viewHeight
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
visible := lines
|
|
if m.scrollOff < len(lines) {
|
|
visible = lines[m.scrollOff:end]
|
|
}
|
|
content := strings.Join(visible, "\n")
|
|
|
|
return header + " " + status + "\n\n" + content
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HandleBack retorna true si debe volver al menú.
|
|
func (m *CommandsModel) HandleBack() bool {
|
|
if m.state == commandsOutput {
|
|
m.state = commandsList
|
|
m.output = ""
|
|
m.scrollOff = 0
|
|
return false
|
|
}
|
|
if m.state == commandsList {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# views/makefiles.go
|
|
# =============================================================================
|
|
log_step "Generando views/makefiles.go..."
|
|
cat > "$PROJECT_DIR/views/makefiles.go" << GOEOF
|
|
package views
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"$DEVFACTORY_MODULE/tui"
|
|
)
|
|
|
|
type makeState int
|
|
|
|
const (
|
|
makeLoading makeState = iota
|
|
makeList
|
|
makeRunning
|
|
makeOutput
|
|
makeEmpty
|
|
)
|
|
|
|
// MakefilesModel gestiona la vista de Makefile targets.
|
|
type MakefilesModel struct {
|
|
state makeState
|
|
list tui.FilteredListModel
|
|
spinner tui.SpinnerModel
|
|
rootDir string
|
|
output string
|
|
exitCode int
|
|
runLabel string
|
|
scrollOff int
|
|
viewHeight int
|
|
}
|
|
|
|
// NewMakefiles crea la vista de Makefile targets.
|
|
func NewMakefiles(rootDir string) MakefilesModel {
|
|
list := tui.NewFilteredList(nil, "Buscar target...")
|
|
list.ListModel = list.ListModel.WithHeight(15).WithWidth(60)
|
|
return MakefilesModel{
|
|
state: makeLoading,
|
|
list: list,
|
|
spinner: tui.NewSpinner("Buscando targets..."),
|
|
rootDir: rootDir,
|
|
viewHeight: 20,
|
|
}
|
|
}
|
|
|
|
// Init lanza el descubrimiento de targets.
|
|
func (m MakefilesModel) Init() tea.Cmd {
|
|
return tea.Batch(m.spinner.Init(), DiscoverMakeTargets(m.rootDir))
|
|
}
|
|
|
|
// Update implementa tea.Model.
|
|
func (m MakefilesModel) Update(msg tea.Msg) (MakefilesModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case DiscoverMakeTargetsMsg:
|
|
if len(msg.Targets) == 0 {
|
|
m.state = makeEmpty
|
|
return m, nil
|
|
}
|
|
items := make([]tui.ListItem, len(msg.Targets))
|
|
for i, t := range msg.Targets {
|
|
items[i] = tui.ListItem{
|
|
Title: t.Name,
|
|
Description: t.Description,
|
|
Value: t.Name,
|
|
}
|
|
}
|
|
m.list = tui.NewFilteredList(items, "Buscar target...")
|
|
m.list.ListModel = m.list.ListModel.WithHeight(15).WithWidth(60)
|
|
m.state = makeList
|
|
return m, nil
|
|
|
|
case ExecResultMsg:
|
|
m.output = msg.Output
|
|
if msg.Error != "" && msg.Output == "" {
|
|
m.output = msg.Error
|
|
}
|
|
m.exitCode = msg.ExitCode
|
|
m.runLabel = msg.Label
|
|
m.state = makeOutput
|
|
m.scrollOff = 0
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch m.state {
|
|
case makeList:
|
|
if msg.String() == "enter" {
|
|
item := m.list.SelectedItem()
|
|
if item != nil {
|
|
target := item.Value.(string)
|
|
m.state = makeRunning
|
|
m.spinner.SetMessage("Ejecutando make " + target + "...")
|
|
return m, tea.Batch(m.spinner.Init(), ExecCommand("make "+target, "make "+target, m.rootDir))
|
|
}
|
|
}
|
|
case makeOutput:
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.scrollOff > 0 {
|
|
m.scrollOff--
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
lines := strings.Split(m.output, "\n")
|
|
if m.scrollOff < len(lines)-m.viewHeight {
|
|
m.scrollOff++
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
m.viewHeight = msg.Height - 6
|
|
}
|
|
|
|
if m.state == makeLoading || m.state == makeRunning {
|
|
updated, cmd := m.spinner.Update(msg)
|
|
m.spinner = updated.(tui.SpinnerModel)
|
|
return m, cmd
|
|
}
|
|
|
|
if m.state == makeList {
|
|
updated, cmd := m.list.Update(msg)
|
|
m.list = updated.(tui.FilteredListModel)
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// View implementa tea.Model.
|
|
func (m MakefilesModel) View() string {
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
|
warnStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("3"))
|
|
|
|
switch m.state {
|
|
case makeLoading:
|
|
return titleStyle.Render("Make") + "\n\n" + m.spinner.View()
|
|
case makeEmpty:
|
|
return titleStyle.Render("Make") + "\n\n" + warnStyle.Render("No se encontró Makefile en "+m.rootDir)
|
|
case makeList:
|
|
return titleStyle.Render("Make") + "\n\n" + m.list.View()
|
|
case makeRunning:
|
|
return titleStyle.Render("Make") + "\n\n" + m.spinner.View()
|
|
case makeOutput:
|
|
header := titleStyle.Render("Make > " + m.runLabel)
|
|
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
|
|
if m.exitCode != 0 {
|
|
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
|
}
|
|
status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode))
|
|
|
|
lines := strings.Split(m.output, "\n")
|
|
end := m.scrollOff + m.viewHeight
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
visible := lines
|
|
if m.scrollOff < len(lines) {
|
|
visible = lines[m.scrollOff:end]
|
|
}
|
|
content := strings.Join(visible, "\n")
|
|
|
|
return header + " " + status + "\n\n" + content
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HandleBack retorna true si debe volver al menú.
|
|
func (m *MakefilesModel) HandleBack() bool {
|
|
if m.state == makeOutput {
|
|
m.state = makeList
|
|
m.output = ""
|
|
m.scrollOff = 0
|
|
return false
|
|
}
|
|
if m.state == makeList || m.state == makeEmpty {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# views/builds.go
|
|
# =============================================================================
|
|
log_step "Generando views/builds.go..."
|
|
cat > "$PROJECT_DIR/views/builds.go" << GOEOF
|
|
package views
|
|
|
|
import (
|
|
"fmt"
|
|
"strings"
|
|
"time"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"$GO_MODULE/config"
|
|
"$DEVFACTORY_MODULE/tui"
|
|
)
|
|
|
|
type buildsState int
|
|
|
|
const (
|
|
buildsList buildsState = iota
|
|
buildsRunning
|
|
buildsOutput
|
|
)
|
|
|
|
// BuildsModel gestiona la vista de builds.
|
|
type BuildsModel struct {
|
|
state buildsState
|
|
list tui.ListModel
|
|
spinner tui.SpinnerModel
|
|
rootDir string
|
|
output string
|
|
exitCode int
|
|
runLabel string
|
|
startTime time.Time
|
|
elapsed time.Duration
|
|
scrollOff int
|
|
viewHeight int
|
|
}
|
|
|
|
// NewBuilds crea la vista de builds.
|
|
func NewBuilds(cfg config.Config) BuildsModel {
|
|
items := make([]tui.ListItem, len(cfg.BuildConfigs))
|
|
for i, b := range cfg.BuildConfigs {
|
|
items[i] = tui.ListItem{
|
|
Title: b.Name,
|
|
Description: b.Description,
|
|
Value: b.Command,
|
|
}
|
|
}
|
|
list := tui.NewList(items)
|
|
list = list.WithHeight(12).WithWidth(60)
|
|
return BuildsModel{
|
|
state: buildsList,
|
|
list: list,
|
|
spinner: tui.NewSpinner("Building..."),
|
|
rootDir: cfg.RootDir,
|
|
viewHeight: 20,
|
|
}
|
|
}
|
|
|
|
// Init implementa tea.Model.
|
|
func (m BuildsModel) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implementa tea.Model.
|
|
func (m BuildsModel) Update(msg tea.Msg) (BuildsModel, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case ExecResultMsg:
|
|
m.elapsed = time.Since(m.startTime)
|
|
m.output = msg.Output
|
|
if msg.Error != "" && msg.Output == "" {
|
|
m.output = msg.Error
|
|
}
|
|
m.exitCode = msg.ExitCode
|
|
m.runLabel = msg.Label
|
|
m.state = buildsOutput
|
|
m.scrollOff = 0
|
|
return m, nil
|
|
|
|
case tea.KeyMsg:
|
|
switch m.state {
|
|
case buildsList:
|
|
if msg.String() == "enter" {
|
|
item := m.list.SelectedItem()
|
|
if item != nil {
|
|
cmd := item.Value.(string)
|
|
m.state = buildsRunning
|
|
m.startTime = time.Now()
|
|
m.spinner.SetMessage("Building " + item.Title + "...")
|
|
return m, tea.Batch(m.spinner.Init(), ExecCommand(item.Title, cmd, m.rootDir))
|
|
}
|
|
}
|
|
case buildsOutput:
|
|
switch msg.String() {
|
|
case "up", "k":
|
|
if m.scrollOff > 0 {
|
|
m.scrollOff--
|
|
}
|
|
return m, nil
|
|
case "down", "j":
|
|
lines := strings.Split(m.output, "\n")
|
|
if m.scrollOff < len(lines)-m.viewHeight {
|
|
m.scrollOff++
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
case tea.WindowSizeMsg:
|
|
m.viewHeight = msg.Height - 6
|
|
}
|
|
|
|
if m.state == buildsRunning {
|
|
updated, cmd := m.spinner.Update(msg)
|
|
m.spinner = updated.(tui.SpinnerModel)
|
|
return m, cmd
|
|
}
|
|
|
|
if m.state == buildsList {
|
|
updated, cmd := m.list.Update(msg)
|
|
m.list = updated.(tui.ListModel)
|
|
return m, cmd
|
|
}
|
|
|
|
return m, nil
|
|
}
|
|
|
|
// View implementa tea.Model.
|
|
func (m BuildsModel) View() string {
|
|
titleStyle := lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("6"))
|
|
|
|
switch m.state {
|
|
case buildsList:
|
|
return titleStyle.Render("Builds") + "\n\n" + m.list.View()
|
|
case buildsRunning:
|
|
return titleStyle.Render("Builds") + "\n\n" + m.spinner.View()
|
|
case buildsOutput:
|
|
header := titleStyle.Render("Builds > " + m.runLabel)
|
|
statusStyle := lipgloss.NewStyle().Foreground(lipgloss.Color("2"))
|
|
if m.exitCode != 0 {
|
|
statusStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("1"))
|
|
}
|
|
status := statusStyle.Render(fmt.Sprintf("Exit: %d", m.exitCode))
|
|
elapsed := lipgloss.NewStyle().Foreground(lipgloss.Color("8")).Render(fmt.Sprintf("(%s)", m.elapsed.Round(time.Millisecond)))
|
|
|
|
lines := strings.Split(m.output, "\n")
|
|
end := m.scrollOff + m.viewHeight
|
|
if end > len(lines) {
|
|
end = len(lines)
|
|
}
|
|
visible := lines
|
|
if m.scrollOff < len(lines) {
|
|
visible = lines[m.scrollOff:end]
|
|
}
|
|
content := strings.Join(visible, "\n")
|
|
|
|
return header + " " + status + " " + elapsed + "\n\n" + content
|
|
}
|
|
return ""
|
|
}
|
|
|
|
// HandleBack retorna true si debe volver al menú.
|
|
func (m *BuildsModel) HandleBack() bool {
|
|
if m.state == buildsOutput {
|
|
m.state = buildsList
|
|
m.output = ""
|
|
m.scrollOff = 0
|
|
return false
|
|
}
|
|
if m.state == buildsList {
|
|
return true
|
|
}
|
|
return false
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# app/model.go — Modelo principal
|
|
# =============================================================================
|
|
log_step "Generando app/model.go..."
|
|
cat > "$PROJECT_DIR/app/model.go" << GOEOF
|
|
package app
|
|
|
|
import (
|
|
"fmt"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
|
|
"$GO_MODULE/config"
|
|
"$GO_MODULE/views"
|
|
"$DEVFACTORY_MODULE/tui"
|
|
)
|
|
|
|
// Model es el modelo principal de la aplicación TUI.
|
|
type Model struct {
|
|
tui.BaseModel
|
|
config config.Config
|
|
currentView views.View
|
|
menu views.MenuModel
|
|
scripts views.ScriptsModel
|
|
commands views.CommandsModel
|
|
makefiles views.MakefilesModel
|
|
builds views.BuildsModel
|
|
width int
|
|
height int
|
|
scriptsInit bool
|
|
makeInit bool
|
|
}
|
|
|
|
// New crea el modelo principal.
|
|
func New(cfg config.Config) Model {
|
|
return Model{
|
|
BaseModel: tui.NewBaseModel(),
|
|
config: cfg,
|
|
currentView: views.ViewMenu,
|
|
menu: views.NewMenu(),
|
|
scripts: views.NewScripts(cfg.RootDir),
|
|
commands: views.NewCommands(cfg),
|
|
makefiles: views.NewMakefiles(cfg.RootDir),
|
|
builds: views.NewBuilds(cfg),
|
|
}
|
|
}
|
|
|
|
// Init implementa tea.Model.
|
|
func (m Model) Init() tea.Cmd {
|
|
return nil
|
|
}
|
|
|
|
// Update implementa tea.Model.
|
|
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
|
switch msg := msg.(type) {
|
|
case tea.WindowSizeMsg:
|
|
m.width = msg.Width
|
|
m.height = msg.Height
|
|
m.SetSize(msg.Width, msg.Height)
|
|
|
|
case tea.KeyMsg:
|
|
key := msg.String()
|
|
|
|
// Ctrl+C siempre sale
|
|
if key == views.KeyQuit {
|
|
return m, tea.Quit
|
|
}
|
|
|
|
// Manejar back (Esc/0)
|
|
if views.IsBack(key) {
|
|
switch m.currentView {
|
|
case views.ViewMenu:
|
|
return m, tea.Quit
|
|
case views.ViewScripts:
|
|
if m.scripts.HandleBack() {
|
|
m.currentView = views.ViewMenu
|
|
m.menu.Reset()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
case views.ViewCommands:
|
|
if m.commands.HandleBack() {
|
|
m.currentView = views.ViewMenu
|
|
m.menu.Reset()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
case views.ViewMakefiles:
|
|
if m.makefiles.HandleBack() {
|
|
m.currentView = views.ViewMenu
|
|
m.menu.Reset()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
case views.ViewBuilds:
|
|
if m.builds.HandleBack() {
|
|
m.currentView = views.ViewMenu
|
|
m.menu.Reset()
|
|
return m, nil
|
|
}
|
|
return m, nil
|
|
}
|
|
}
|
|
}
|
|
|
|
// Delegar al sub-modelo activo
|
|
var cmd tea.Cmd
|
|
switch m.currentView {
|
|
case views.ViewMenu:
|
|
m.menu, cmd = m.menu.Update(msg)
|
|
if m.menu.IsDone() {
|
|
selected := m.menu.SelectedView()
|
|
m.currentView = selected
|
|
m.menu.Reset()
|
|
switch selected {
|
|
case views.ViewScripts:
|
|
if !m.scriptsInit {
|
|
m.scriptsInit = true
|
|
return m, m.scripts.Init()
|
|
}
|
|
case views.ViewMakefiles:
|
|
if !m.makeInit {
|
|
m.makeInit = true
|
|
return m, m.makefiles.Init()
|
|
}
|
|
}
|
|
return m, nil
|
|
}
|
|
case views.ViewScripts:
|
|
m.scripts, cmd = m.scripts.Update(msg)
|
|
case views.ViewCommands:
|
|
m.commands, cmd = m.commands.Update(msg)
|
|
case views.ViewMakefiles:
|
|
m.makefiles, cmd = m.makefiles.Update(msg)
|
|
case views.ViewBuilds:
|
|
m.builds, cmd = m.builds.Update(msg)
|
|
}
|
|
|
|
return m, cmd
|
|
}
|
|
|
|
// View implementa tea.Model.
|
|
func (m Model) View() string {
|
|
headerStyle := lipgloss.NewStyle().
|
|
Bold(true).
|
|
Foreground(lipgloss.Color("0")).
|
|
Background(lipgloss.Color("6")).
|
|
Padding(0, 1).
|
|
Width(m.width)
|
|
|
|
header := headerStyle.Render(fmt.Sprintf(" TUI Manager — %s", m.config.RootDir))
|
|
|
|
var content string
|
|
switch m.currentView {
|
|
case views.ViewMenu:
|
|
content = m.menu.View()
|
|
case views.ViewScripts:
|
|
content = m.scripts.View()
|
|
case views.ViewCommands:
|
|
content = m.commands.View()
|
|
case views.ViewMakefiles:
|
|
content = m.makefiles.View()
|
|
case views.ViewBuilds:
|
|
content = m.builds.View()
|
|
}
|
|
|
|
statusStyle := lipgloss.NewStyle().
|
|
Foreground(lipgloss.Color("8")).
|
|
Width(m.width)
|
|
|
|
var hints string
|
|
switch m.currentView {
|
|
case views.ViewMenu:
|
|
hints = "↑↓: navegar | Enter: seleccionar | Esc: salir"
|
|
default:
|
|
hints = "↑↓: navegar | Enter: ejecutar | /: filtrar | Esc/0: volver"
|
|
}
|
|
statusBar := statusStyle.Render(hints)
|
|
|
|
return header + "\n\n" + content + "\n\n" + statusBar
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# main.go
|
|
# =============================================================================
|
|
log_step "Generando main.go..."
|
|
cat > "$PROJECT_DIR/main.go" << GOEOF
|
|
package main
|
|
|
|
import (
|
|
"fmt"
|
|
"os"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
|
|
"$GO_MODULE/app"
|
|
"$GO_MODULE/config"
|
|
)
|
|
|
|
func main() {
|
|
dir := "."
|
|
if len(os.Args) > 1 {
|
|
dir = os.Args[1]
|
|
}
|
|
|
|
cfg := config.New(dir)
|
|
model := app.New(cfg)
|
|
|
|
p := tea.NewProgram(model, tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
fmt.Fprintf(os.Stderr, "Error: %v\n", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
GOEOF
|
|
|
|
# =============================================================================
|
|
# Makefile
|
|
# =============================================================================
|
|
log_step "Generando Makefile..."
|
|
cat > "$PROJECT_DIR/Makefile" << MKEOF
|
|
.PHONY: run build test clean install help
|
|
|
|
MODULE_NAME := $MODULE_NAME
|
|
BUILD_DIR := build
|
|
|
|
## run: Ejecutar el TUI en modo desarrollo
|
|
run:
|
|
go run . .
|
|
|
|
## build: Compilar el binario
|
|
build:
|
|
@mkdir -p \$(BUILD_DIR)
|
|
go build -trimpath -ldflags='-s -w' -o \$(BUILD_DIR)/\$(MODULE_NAME) .
|
|
@echo "Built \$(BUILD_DIR)/\$(MODULE_NAME)"
|
|
|
|
## test: Ejecutar tests
|
|
test:
|
|
go test ./... -v
|
|
|
|
## clean: Limpiar artefactos
|
|
clean:
|
|
rm -rf \$(BUILD_DIR)
|
|
|
|
## install: Instalar en ~/.local/bin
|
|
install: build
|
|
cp \$(BUILD_DIR)/\$(MODULE_NAME) \$(HOME)/.local/bin/\$(MODULE_NAME)
|
|
@echo "Installed to \$(HOME)/.local/bin/\$(MODULE_NAME)"
|
|
|
|
## tidy: go mod tidy
|
|
tidy:
|
|
go mod tidy
|
|
|
|
## help: Mostrar esta ayuda
|
|
help:
|
|
@grep -E '^## ' Makefile | sed 's/## //' | column -t -s ':'
|
|
MKEOF
|
|
|
|
# =============================================================================
|
|
# .gitignore
|
|
# =============================================================================
|
|
cat > "$PROJECT_DIR/.gitignore" << 'EOF'
|
|
build/
|
|
*.exe
|
|
*.dll
|
|
*.so
|
|
*.dylib
|
|
EOF
|
|
|
|
# =============================================================================
|
|
# go mod tidy
|
|
# =============================================================================
|
|
log_step "Ejecutando go mod tidy..."
|
|
cd "$PROJECT_DIR"
|
|
go mod tidy 2>/dev/null || log_warn "go mod tidy falló — revisa el go.work"
|
|
|
|
# =============================================================================
|
|
# Resumen
|
|
# =============================================================================
|
|
echo ""
|
|
log_ok "Proyecto TUI '$MODULE_NAME' creado en $PROJECT_DIR"
|
|
echo ""
|
|
echo -e "${CYAN}Estructura:${NC}"
|
|
echo " $MODULE_NAME/"
|
|
echo " ├── main.go — Entry point (fullscreen TUI)"
|
|
echo " ├── app/"
|
|
echo " │ └── model.go — Modelo principal y navegación"
|
|
echo " ├── views/"
|
|
echo " │ ├── keys.go — Constantes de teclas"
|
|
echo " │ ├── exec.go — Comandos async y descubrimiento"
|
|
echo " │ ├── menu.go — Menú principal (4 opciones)"
|
|
echo " │ ├── scripts.go — Buscar y ejecutar scripts .sh"
|
|
echo " │ ├── commands.go — Comandos frecuentes"
|
|
echo " │ ├── makefiles.go — Targets del Makefile"
|
|
echo " │ └── builds.go — Configuraciones de build"
|
|
echo " ├── config/"
|
|
echo " │ └── config.go — Configuración (editable)"
|
|
echo " ├── Makefile — run, build, test, install"
|
|
echo " └── go.work — Enlace a DevFactory"
|
|
echo ""
|
|
echo -e "${CYAN}Comandos:${NC}"
|
|
echo " make run — Ejecutar el TUI"
|
|
echo " make build — Compilar binario"
|
|
echo " make install — Instalar en ~/.local/bin"
|
|
echo " make test — Ejecutar tests"
|
|
echo ""
|
|
echo -e "${CYAN}Navegación:${NC}"
|
|
echo " ↑↓ — Moverse por las listas"
|
|
echo " Enter — Seleccionar / ejecutar"
|
|
echo " / — Filtrar (en scripts y make)"
|
|
echo " Esc / 0 — Volver atrás"
|
|
echo " Esc (menú) — Salir del TUI"
|
|
echo ""
|
|
echo "STATUS: READY"
|