Version final

This commit is contained in:
luisgulo committed 2026-10-31 18:32:28 +01:00
1 parent 2d4b09b9ea
commit 74b7a0c448
13 files changed
+1103 -7

No files matched your search

+179
View File
@@ -0,0 +1,179 @@
package ui
import (
"registry-tui/client"
"sort"
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
type ViewState int
const (
StateLoading ViewState = iota
StateMainTable
StateConfirmDelete
StateNewTag
StateError
StateRegistryDown
)
type AppMode int
const (
ModeRegistry AppMode = iota
ModeLocal
)
type ImageRow = client.ImageRowData
type Model struct {
state ViewState
mode AppMode
client *client.RegistryClient
rows []ImageRow
localRows []ImageRow
registryRows []ImageRow
cursor int
err error
statusMsg string
textInput textinput.Model
version string
}
func NewModel(registryURL string, version string) Model {
ti := textinput.New()
ti.Placeholder = "ej. proyecto-web/alpine:v1.0"
ti.CharLimit = 128
ti.Width = 35
return Model{
state: StateLoading,
mode: ModeRegistry,
client: client.NewRegistryClient(registryURL),
textInput: ti,
version: version,
}
}
func (m Model) Init() tea.Cmd {
return fetchRegistryImagesCmd(m.client)
}
type imagesMsg struct {
rows []ImageRow
mode AppMode
}
type errorMsg error
type successMsg string
func fetchRegistryImagesCmd(c *client.RegistryClient) tea.Cmd {
return func() tea.Msg {
repos, err := c.GetCatalog()
if err != nil {
return errorMsg(err)
}
var rows []ImageRow
for _, repo := range repos {
tags, err := c.GetTags(repo)
if err != nil {
continue
}
var project, imageName string
parts := strings.Split(repo, "/")
if len(parts) > 1 {
project = parts[0]
imageName = strings.Join(parts[1:], "/")
} else {
project = ""
imageName = repo
}
for _, tag := range tags {
rows = append(rows, ImageRow{
Project: project,
RepoName: repo,
ImageName: imageName,
Tag: tag,
})
}
}
sortRows(rows)
return imagesMsg{rows: rows, mode: ModeRegistry}
}
}
func fetchLocalImagesCmd(c *client.RegistryClient) tea.Cmd {
return func() tea.Msg {
rows, err := c.GetLocalImages()
if err != nil {
return errorMsg(err)
}
sortRows(rows)
return imagesMsg{rows: rows, mode: ModeLocal}
}
}
func sortRows(rows []ImageRow) {
sort.Slice(rows, func(i, j int) bool {
if rows[i].Project == rows[j].Project {
return rows[i].RepoName < rows[j].RepoName
}
return rows[i].Project < rows[j].Project
})
}
func deleteTagCmd(c *client.RegistryClient, repo, tag string) tea.Cmd {
return func() tea.Msg {
err := c.DeleteManifest(repo, tag)
if err != nil {
return errorMsg(err)
}
return successMsg("Tag borrado con éxito del registro")
}
}
func deleteLocalImageCmd(c *client.RegistryClient, repo, tag string) tea.Cmd {
return func() tea.Msg {
err := c.DeleteLocalImage(repo, tag)
if err != nil {
return errorMsg(err)
}
return successMsg("Imagen local borrada con éxito")
}
}
func copyImageCmd(c *client.RegistryClient, sourceRepo, sourceTag, destRepo, destTag string) tea.Cmd {
return func() tea.Msg {
err := c.CopyManifest(sourceRepo, sourceTag, destRepo, destTag)
if err != nil {
return errorMsg(err)
}
return successMsg("Imagen reestructurada con éxito en el registro")
}
}
func tagLocalImageCmd(c *client.RegistryClient, sourceRepo, sourceTag, destRepo, destTag string) tea.Cmd {
return func() tea.Msg {
err := c.TagLocalImage(sourceRepo, sourceTag, destRepo, destTag)
if err != nil {
return errorMsg(err)
}
return successMsg("Imagen local etiquetada con éxito")
}
}
func pushImageCmd(c *client.RegistryClient, repo, tag string) tea.Cmd {
return func() tea.Msg {
err := c.PushLocalImage(repo, tag)
if err != nil {
return errorMsg(err)
}
return successMsg("Imagen subida (Push) al registro con éxito")
}
}
+175
View File
@@ -0,0 +1,175 @@
package ui
import (
"strings"
"github.com/charmbracelet/bubbles/textinput"
tea "github.com/charmbracelet/bubbletea"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// 1. Manejo especial cuando el Registry está caído (StateRegistryDown)
if m.state == StateRegistryDown {
if keyMsg, ok := msg.(tea.KeyMsg); ok {
switch keyMsg.String() {
case "enter", "L", "l":
m.state = StateLoading
m.mode = ModeLocal
m.statusMsg = ""
return m, fetchLocalImagesCmd(m.client)
case "q", "ctrl+c", "CTRL+C":
return m, tea.Quit
}
}
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
if m.state == StateNewTag {
switch msg.String() {
case "esc":
m.state = StateMainTable
m.textInput.Reset()
return m, nil
case "enter":
inputVal := m.textInput.Value()
if inputVal != "" {
lastColon := strings.LastIndex(inputVal, ":")
var destRepo, destTag string
if lastColon != -1 {
destRepo = inputVal[:lastColon]
destTag = inputVal[lastColon+1:]
} else {
destRepo = inputVal
destTag = "latest"
}
if destRepo != "" && destTag != "" {
selectedRow := m.rows[m.cursor]
m.state = StateLoading
m.textInput.Reset()
if m.mode == ModeLocal {
return m, tagLocalImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag, destRepo, destTag)
}
return m, copyImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag, destRepo, destTag)
}
}
return m, nil
}
var cmd tea.Cmd
m.textInput, cmd = m.textInput.Update(msg)
return m, cmd
}
switch msg.String() {
case "ctrl+c", "q":
if m.state == StateConfirmDelete {
m.state = StateMainTable
return m, nil
}
return m, tea.Quit
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < len(m.rows)-1 {
m.cursor++
}
case "r":
if m.state == StateMainTable && m.mode != ModeRegistry {
m.state = StateLoading
m.cursor = 0
m.statusMsg = ""
m.mode = ModeRegistry
return m, fetchRegistryImagesCmd(m.client)
}
case "l":
if m.state == StateMainTable && m.mode != ModeLocal {
m.state = StateLoading
m.cursor = 0
m.statusMsg = ""
m.mode = ModeLocal
return m, fetchLocalImagesCmd(m.client)
}
case "d":
if m.state == StateMainTable && len(m.rows) > 0 {
m.state = StateConfirmDelete
}
case "t":
if m.state == StateMainTable && len(m.rows) > 0 {
m.state = StateNewTag
m.textInput.Focus()
return m, textinput.Blink
}
case "p":
if m.state == StateMainTable && m.mode == ModeLocal && len(m.rows) > 0 {
selectedRow := m.rows[m.cursor]
m.state = StateLoading
return m, pushImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag)
}
case "y":
if m.state == StateConfirmDelete {
selectedRow := m.rows[m.cursor]
m.state = StateLoading
if m.mode == ModeLocal {
return m, deleteLocalImageCmd(m.client, selectedRow.RepoName, selectedRow.Tag)
}
return m, deleteTagCmd(m.client, selectedRow.RepoName, selectedRow.Tag)
}
case "n":
if m.state == StateConfirmDelete {
m.state = StateMainTable
}
}
case imagesMsg:
m.rows = msg.rows
if msg.mode == ModeRegistry {
m.registryRows = msg.rows
} else {
m.localRows = msg.rows
}
m.state = StateMainTable
m.cursor = 0
case successMsg:
m.statusMsg = string(msg)
m.state = StateLoading
if m.mode == ModeRegistry {
return m, fetchRegistryImagesCmd(m.client)
} else {
return m, fetchLocalImagesCmd(m.client)
}
case errorMsg:
errVal := error(msg)
errMsgStr := errVal.Error()
// Comprobamos si el error es de conexión con el registro
if strings.Contains(errMsgStr, "connection refused") ||
strings.Contains(errMsgStr, "dial tcp") ||
strings.Contains(errMsgStr, "Get \"http://localhost:5000") {
m.state = StateRegistryDown
m.statusMsg = errMsgStr
return m, nil
}
// Error estándar para otros casos
m.err = errVal
m.state = StateError
return m, nil
}
return m, nil
}
+198
View File
@@ -0,0 +1,198 @@
package ui
import (
"fmt"
"github.com/charmbracelet/lipgloss"
)
var (
titleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#900C3F")).
Padding(0, 1)
headerBgStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#1F618D"))
localHeaderBgStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#B7950B"))
selectedRowStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#2980B9")).
Bold(true)
normalProjectStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#F4D03F")).
Bold(true)
normalImageStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#D5D8DC"))
fullNameStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#00FFFF")).
Bold(true)
footerKeyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#000000")).
Background(lipgloss.Color("#F4D03F")).
Bold(true)
footerDescStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FFFFFF")).
Background(lipgloss.Color("#2C3E50"))
errorBoxStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF5F56")).
Bold(true)
successStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#2ECC71")).
Bold(true)
)
func (m Model) View() string {
if m.state == StateRegistryDown {
return m.viewRegistryDown()
}
switch m.state {
case StateLoading:
modeText := "del Registro Docker"
if m.mode == ModeLocal {
modeText = "Locales de Docker"
}
return fmt.Sprintf("\n Cargando imágenes %s...\n", modeText)
case StateError:
return fmt.Sprintf("\n %s\n\n [Presiona 'q' para salir / 'r' o 'l' para cambiar de modo]\n", errorBoxStyle.Render("Error: "+m.err.Error()))
case StateMainTable:
s := ""
modeStr := "REGISTRY"
styleHeader := headerBgStyle
if m.mode == ModeLocal {
modeStr = "LOCAL"
styleHeader = localHeaderBgStyle
}
titleBar := fmt.Sprintf(" REGISTRY-TUI v%s [%s] ", m.version, modeStr)
s += styleHeader.Width(70).Render(titleBar) + "\n"
s += "┌───────────────────────┬──────────────────────────────────────────┐\n"
s += fmt.Sprintf("│ %-21s │ %-40s │\n", "PROYECTO / RUTA", "IMAGEN : VERSION")
s += "├───────────────────────┼──────────────────────────────────────────┤\n"
if len(m.rows) == 0 {
msgEmpty := "No hay imágenes en el registro"
if m.mode == ModeLocal {
msgEmpty = "No se encontraron imágenes locales"
}
s += fmt.Sprintf("│ %-21s │ %-40s │\n", "", msgEmpty)
s += "└───────────────────────┴──────────────────────────────────────────┘\n"
} else {
for i, row := range m.rows {
displayProj := row.Project
if displayProj == "(raíz)" {
displayProj = ""
}
imageVersion := fmt.Sprintf("%s:%s", row.ImageName, row.Tag)
isSelected := (m.cursor == i)
var lineContent string
if isSelected {
projFormatted := fmt.Sprintf(" %-21s ", displayProj)
imgFormatted := fmt.Sprintf(" %-40s ", imageVersion)
renderedProj := selectedRowStyle.Render(projFormatted)
renderedImg := selectedRowStyle.Render(imgFormatted)
lineContent = fmt.Sprintf("│%s│%s│", renderedProj, renderedImg)
} else {
projFormatted := normalProjectStyle.Render(fmt.Sprintf("%-21s", displayProj))
imgFormatted := normalImageStyle.Render(fmt.Sprintf("%-40s", imageVersion))
lineContent = fmt.Sprintf("│ %s │ %s │", projFormatted, imgFormatted)
}
s += lineContent + "\n"
if i < len(m.rows)-1 {
s += "├───────────────────────┼──────────────────────────────────────────┤\n"
}
}
s += "└───────────────────────┴──────────────────────────────────────────┘\n"
}
// Ruta completa seleccionada
if len(m.rows) > 0 {
selected := m.rows[m.cursor]
fullRef := fmt.Sprintf("%s:%s", selected.RepoName, selected.Tag)
s += fmt.Sprintf(" Ruta completa: %s\n", fullNameStyle.Render(fullRef))
} else {
s += "\n"
}
total := len(m.rows)
statusInfo := fmt.Sprintf(" Total: %d imágenes (%s)", total, modeStr)
if m.statusMsg != "" {
statusInfo = " " + successStyle.Render("✔ "+m.statusMsg)
}
s += fmt.Sprintf("%-70s\n\n", statusInfo)
// Barra de botones inferior estilo MC uniforme para ambos modos
btnR := footerKeyStyle.Render(" R ") + footerDescStyle.Render(" Registry ")
btnL := footerKeyStyle.Render(" L ") + footerDescStyle.Render(" Local ")
btnT := footerKeyStyle.Render(" t ") + footerDescStyle.Render(" Tagear ")
btnD := footerKeyStyle.Render(" d ") + footerDescStyle.Render(" Borrar ")
btnP := footerKeyStyle.Render(" p ") + footerDescStyle.Render(" Push ")
btnQ := footerKeyStyle.Render(" q ") + footerDescStyle.Render(" Salir ")
if m.mode == ModeLocal {
s += fmt.Sprintf(" %s %s %s %s %s %s\n", btnR, btnL, btnP, btnT, btnD, btnQ)
} else {
s += fmt.Sprintf(" %s %s %s %s %s\n", btnR, btnL, btnT, btnD, btnQ)
}
return s
case StateConfirmDelete:
selectedRow := m.rows[m.cursor]
s := headerBgStyle.Width(50).Render(" Confirmar Borrado ") + "\n\n"
s += fmt.Sprintf(" ¿Estás seguro de que deseas borrar la imagen:\n -> %s:%s?\n\n", selectedRow.RepoName, selectedRow.Tag)
if m.mode == ModeLocal {
s += errorBoxStyle.Render(" ¡Esta acción eliminará la imagen de tu sistema local!\n\n")
} else {
s += errorBoxStyle.Render(" ¡Esta acción eliminará el manifest permanentemente del registro!\n\n")
}
s += footerKeyStyle.Render(" y ") + footerDescStyle.Render(" Sí, borrar ") + " " + footerKeyStyle.Render(" n ") + footerDescStyle.Render(" Cancelar ")
return s
case StateNewTag:
selectedRow := m.rows[m.cursor]
s := headerBgStyle.Width(50).Render(" Reestructurar / Tagear Imagen ") + "\n\n"
s += fmt.Sprintf(" Origen: %s:%s\n\n", selectedRow.RepoName, selectedRow.Tag)
s += " Introduce la ruta completa de destino\n (ej: proyecto-web/alpine:v1.0):\n\n"
s += " " + m.textInput.View() + "\n\n"
s += footerKeyStyle.Render(" Enter ") + footerDescStyle.Render(" Confirmar ") + " " + footerKeyStyle.Render(" Esc ") + footerDescStyle.Render(" Cancelar ")
return s
}
return ""
}
func (m Model) viewRegistryDown() string {
s := titleStyle.Render(" ⚠️ ADVERTENCIA DE CONEXIÓN - REGISTRY-TUI ") + "\n\n"
s += fmt.Sprintf(" No se puede conectar al Registry local:\n %s\n\n", m.statusMsg)
s += " ─────────────────────────────────────────────────────────\n"
s += " [ENTER / L] Cambiar directamente al modo LOCAL\n"
s += " [ q ] Salir de la aplicación\n"
return lipgloss.NewStyle().Margin(2, 4).Render(s)
}