Browse Source

feat: main flow created

master
jb 3 weeks ago
parent
commit
6870144f39
16 changed files with 689 additions and 210 deletions
  1. +1
    -1
      cmd/main.go
  2. +1
    -1
      go.mod
  3. +27
    -0
      internal/docker/docker.go
  4. +47
    -0
      internal/tui/cmds.go
  5. +186
    -0
      internal/tui/form.go
  6. +112
    -0
      internal/tui/model.go
  7. +7
    -1
      internal/tui/steps.go
  8. +56
    -0
      internal/tui/styles.go
  9. +137
    -0
      internal/tui/update.go
  10. +115
    -0
      internal/tui/view.go
  11. +0
    -28
      tui/cmds.go
  12. +0
    -1
      tui/docker.go
  13. +0
    -25
      tui/model.go
  14. +0
    -12
      tui/styles.go
  15. +0
    -76
      tui/update.go
  16. +0
    -65
      tui/view.go

+ 1
- 1
cmd/main.go View File

@ -5,7 +5,7 @@ import (
"os" "os"
tea "charm.land/bubbletea/v2" tea "charm.land/bubbletea/v2"
"git.davinti.com.br/davinTI/app-dono/tui/tui"
"git.davinti.com.br/davinTI/app-dono/tui/internal/tui"
) )
func main() { func main() {


+ 1
- 1
go.mod View File

@ -5,10 +5,10 @@ go 1.25.0
require ( require (
charm.land/bubbles/v2 v2.0.0 charm.land/bubbles/v2 v2.0.0
charm.land/bubbletea/v2 v2.0.1 charm.land/bubbletea/v2 v2.0.1
charm.land/lipgloss/v2 v2.0.0
) )
require ( require (
charm.land/lipgloss/v2 v2.0.0 // indirect
github.com/atotto/clipboard v0.1.4 // indirect github.com/atotto/clipboard v0.1.4 // indirect
github.com/charmbracelet/colorprofile v0.4.2 // indirect github.com/charmbracelet/colorprofile v0.4.2 // indirect
github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect github.com/charmbracelet/ultraviolet v0.0.0-20260205113103-524a6607adb8 // indirect


+ 27
- 0
internal/docker/docker.go View File

@ -0,0 +1,27 @@
package tui
import (
"fmt"
"os/exec"
)
func RunContainer(image string, name string, port int) error {
cmd := exec.Command(
"docker", "run",
"-d",
"--name", name,
"-p", fmt.Sprintf("%d:%d", port, port),
image,
)
return cmd.Run()
}
func PullImage(image string) (string, error) {
cmd := exec.Command("docker", "pull", image)
out, err := cmd.CombinedOutput()
return string(out), err
}

+ 47
- 0
internal/tui/cmds.go View File

@ -0,0 +1,47 @@
package tui
import (
"os/exec"
"time"
tea "charm.land/bubbletea/v2"
)
// --- Messages ---
// Docker installation check
type DockerCheckedMsg struct{ Installed bool }
type DockerInstalledMsg struct{ Err error }
type TickMsg struct{}
// Image downloading
type ImageDownloadFinishedMsg struct {
Err error
}
type DownloadTickMsg struct{}
// --- Commands ---
func CheckDockerCmd() tea.Cmd {
return func() tea.Msg {
_, err := exec.LookPath("docker")
return DockerCheckedMsg{Installed: err == nil}
}
}
func tickCmd() tea.Cmd {
return tea.Tick(100*time.Millisecond, func(t time.Time) tea.Msg {
return TickMsg{}
})
}
func downloadImageCmd() tea.Cmd {
return func() tea.Msg {
err := docker.PullImage("hub.davinti.com.br:443/app-dono/app-cliente:latest")
return ImageDownloadFinishedMsg{
Err: err,
}
}
}

+ 186
- 0
internal/tui/form.go View File

@ -0,0 +1,186 @@
package tui
import (
"fmt"
"strings"
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
)
type FieldType int
const (
FieldTypeText FieldType = iota
FieldTypeSelect
FieldTypePassword
FieldTypeNumber
)
type FormField struct {
Label string
Placeholder string
Default string
Type FieldType
Options []string // for FieldTypeSelect
optionIdx int // for FieldTypeSelect
CharLimit int
input textinput.Model
}
type FormStep struct {
Title string
Fields []FormField
focus int
}
func NewFormStep(title string, fields []FormField) FormStep {
f := FormStep{Title: title, Fields: fields}
for i := range f.Fields {
if f.Fields[i].Type == FieldTypeSelect {
for j, opt := range f.Fields[i].Options {
// Apply default option
if opt == f.Fields[i].Default {
f.Fields[i].optionIdx = j
break
}
}
continue
}
ti := textinput.New()
ti.Placeholder = f.Fields[i].Placeholder
if f.Fields[i].Default != "" {
ti.SetValue(f.Fields[i].Default)
}
if f.Fields[i].CharLimit > 0 {
ti.CharLimit = f.Fields[i].CharLimit
}
if f.Fields[i].Type == FieldTypePassword {
ti.EchoMode = textinput.EchoPassword
}
if i == 0 {
ti.Focus()
}
f.Fields[i].input = ti
}
return f
}
// Update
func (f *FormStep) Update(msg tea.Msg) (done bool, cmd tea.Cmd) {
focused := &f.Fields[f.focus]
isSelect := focused.Type == FieldTypeSelect
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
// Select specific inputs
case "left", "h":
if isSelect && focused.optionIdx > 0 {
focused.optionIdx--
}
case "right", "l":
if isSelect && focused.optionIdx < len(focused.Options)-1 {
focused.optionIdx++
}
// Generic inputs
case "tab", "down":
f.blurCurrent()
f.focus = (f.focus + 1) % len(f.Fields)
f.focusCurrent()
case "shift+tab", "up":
f.blurCurrent()
f.focus = (f.focus - 1 + len(f.Fields)) % len(f.Fields)
f.focusCurrent()
case "enter":
if f.focus == len(f.Fields)-1 {
return true, nil
}
f.blurCurrent()
f.focus++
f.focusCurrent()
}
}
var c tea.Cmd
f.Fields[f.focus].input, c = f.Fields[f.focus].input.Update(msg)
return false, c
}
func (f *FormStep) Values() map[string]string {
out := make(map[string]string)
for _, field := range f.Fields {
if field.Type == FieldTypeSelect {
out[field.Label] = field.Options[field.optionIdx]
} else {
out[field.Label] = field.input.Value()
}
}
return out
}
// View
func (f FormStep) View() string {
pad := strings.Repeat(" ", padding)
var sb strings.Builder
sb.WriteString(pad + TitleStyle.Render(f.Title) + "")
for i, field := range f.Fields {
sb.WriteString(renderField(field, i == f.focus))
}
return sb.String()
}
func renderField(field FormField, focused bool) string {
pad := strings.Repeat(" ", padding)
label := " " + field.Label
if focused {
label = CursorStyle.Render("> " + field.Label)
}
var value string
switch field.Type {
case FieldTypeSelect:
var opts []string
for i, opt := range field.Options {
if i == field.optionIdx {
opts = append(opts, SelectedStyle.Render("[ "+opt+" ]"))
} else {
opts = append(opts, DimStyle.Render(" "+opt+" "))
}
}
value = strings.Join(opts, " ")
if focused {
value += HelpStyle.Render(" ← →")
}
default:
value = field.input.View()
}
return fmt.Sprintf("\n\n%s%s\n%s %s", pad, label, pad, value)
}
// Helpers
func (f *FormStep) blurCurrent() {
if f.Fields[f.focus].Type != FieldTypeSelect {
f.Fields[f.focus].input.Blur()
}
}
func (f *FormStep) focusCurrent() {
if f.Fields[f.focus].Type != FieldTypeSelect {
f.Fields[f.focus].input.Focus()
}
}

+ 112
- 0
internal/tui/model.go View File

@ -0,0 +1,112 @@
package tui
import (
tea "charm.land/bubbletea/v2"
)
type Model struct {
currentStep step
cursor int
dockerInstalled bool
checkDockerDone bool
checkProgress float64
isPublicIP bool
serverForm FormStep
dbForm FormStep
certForm FormStep
configValues ConfigValues
err error
}
type ConfigValues struct {
Server map[string]string
Database map[string]string
Cert map[string]string
}
func InitialModel() Model {
return Model{
currentStep: StepCheckDocker,
serverForm: NewFormStep("Servidor", []FormField{
{
Label: "Porta",
Placeholder: "8081",
Default: "8081",
Type: FieldTypeNumber,
CharLimit: 4,
},
{
Label: "Timeout (Segundos)",
Placeholder: "30",
Default: "30",
Type: FieldTypeNumber,
CharLimit: 3,
},
{
Label: "Ambiente",
Default: "production",
Type: FieldTypeSelect,
Options: []string{"development", "production"},
},
}),
dbForm: NewFormStep("Banco de Dados", []FormField{
{
Label: "Tipo do Banco",
Default: "postgres",
Type: FieldTypeSelect,
Options: []string{"postgres", "oracle"},
},
{
Label: "URL de acesso",
Placeholder: "postgres://usuario:senha@banco:5432/app_dono_db",
Default: "postgres://usuario:senha@banco:5432/app_dono_db",
Type: FieldTypeText,
},
{
Label: "Conexões ativas (máximo)",
Placeholder: "10",
Default: "10",
Type: FieldTypeNumber,
},
{
Label: "Conexões ativas (mínimo)",
Placeholder: "2",
Default: "2",
Type: FieldTypeNumber,
},
}),
certForm: NewFormStep("Certificado", []FormField{
{
Label: "Caminho para o arquivo do certificado",
Placeholder: "/caminho/para/certificado.crt",
Default: "/caminho/para/certificado.crt",
Type: FieldTypeText,
},
{
Label: "Caminho para o arquivo da chave",
Placeholder: "/caminho/para/chave.key",
Default: "/caminho/para/chave.key",
Type: FieldTypeText,
},
{
Label: "Caminho para o arquivo da autoridade certificadora",
Placeholder: "/caminho/para/chaveCA.crt",
Default: "/caminho/para/chaveCA.crt",
Type: FieldTypeText,
},
{
Label: "Nome do servidor",
Placeholder: "client",
Default: "client",
Type: FieldTypeText,
},
}),
}
}
func (m Model) Init() tea.Cmd {
return tea.Batch(CheckDockerCmd(), tickCmd())
}

tui/steps.go → internal/tui/steps.go View File

@ -5,8 +5,14 @@ type step int
const ( const (
StepCheckDocker step = iota StepCheckDocker step = iota
StepDockerInstall StepDockerInstall
StepDownloadImage
StepIPQuestion StepIPQuestion
StepInstallWireguard StepInstallWireguard
StepTextInputs
StepServerConfig
StepDatabaseConfig
StepCertConfig
StepDone StepDone
) )

+ 56
- 0
internal/tui/styles.go View File

@ -0,0 +1,56 @@
package tui
import "charm.land/lipgloss/v2"
const padding = 2
const (
primary = "#005F87" // Deep Sea Blue
secondary = "#00D7FF" // Cyan/Data highlight
success = "#06D6A0" // Emerald Green
warning = "#FFD166" // Soft Gold
danger = "#EF476F" // Muted Crimson
neutral = "#8E9AAF" // Slate Gray
darkGray = "#353B48"
)
var (
// Core Text Styles
TitleStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(primary)).
Bold(true).
MarginBottom(1)
CursorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(secondary))
HelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262"))
// Status Styles
ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(danger))
InfoStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(primary))
WarnStyle = lipgloss.NewStyle().Foreground(lipgloss.Color(warning))
// Form & Selection
SelectedStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(secondary)).
Bold(true).
PaddingLeft(1)
DimStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#4A4A4A"))
// Data Components (Progress / Charts)
ProgressFillStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(primary))
ProgressEmptyStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(darkGray))
// Table Styles
HeaderStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color(secondary)).
Bold(true).
Border(lipgloss.NormalBorder(), false, false, true)
)

+ 137
- 0
internal/tui/update.go View File

@ -0,0 +1,137 @@
package tui
import (
"math/rand"
tea "charm.land/bubbletea/v2"
)
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c":
return m, tea.Quit
}
}
switch m.currentStep {
case StepCheckDocker:
return m.updateCheckDocker(msg)
case StepDockerInstall:
return m.updateDockerInstall(msg)
case StepDownloadImage:
return m.updateDownloadImage(msg)
case StepIPQuestion:
return m.updateIPQuestion(msg)
//case StepInstallWireguard
// return m.updateInstallWireguard(msg)
case StepServerConfig:
done, cmd := m.serverForm.Update(msg)
if done {
m.currentStep = StepDatabaseConfig
}
return m, cmd
case StepDatabaseConfig:
done, cmd := m.dbForm.Update(msg)
if done {
m.currentStep = StepCertConfig
}
return m, cmd
case StepCertConfig:
done, cmd := m.certForm.Update(msg)
if done {
m.currentStep = StepDone
}
return m, cmd
case StepDone:
return m, tea.Quit
}
return m, nil
}
func (m Model) updateCheckDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case DockerCheckedMsg:
m.dockerInstalled = msg.Installed
m.checkDockerDone = true
case TickMsg:
if m.checkProgress < 1.0 {
m.checkProgress += 0.15 + (rand.Float64()*0.15 - 0.15)
if m.checkProgress > 1.0 {
m.checkProgress = 1.0
}
return m, tickCmd()
}
case tea.KeyPressMsg:
if m.checkDockerDone {
if m.dockerInstalled {
m.currentStep = StepIPQuestion
} else {
m.currentStep = StepDockerInstall
}
}
}
return m, nil
}
func (m Model) updateDockerInstall(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyPressMsg:
return m, tea.Quit
case DockerInstalledMsg:
if msg.Err != nil {
m.err = msg.Err
} else {
m.currentStep = StepIPQuestion
}
}
return m, nil
}
func (m Model) updateIPQuestion(msg tea.Msg) (tea.Model, tea.Cmd) {
numOptions := 2
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < numOptions-1 {
m.cursor++
}
case "enter":
// Yes
if m.cursor == 0 {
m.currentStep = StepServerConfig
return m, nil
}
m.currentStep = StepInstallWireguard
return m, nil
}
}
return m, nil
}
func (m Model) updateWaitForProgramQuit(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg.(type) {
case tea.KeyPressMsg:
return m, tea.Quit
}
return m, nil
}

+ 115
- 0
internal/tui/view.go View File

@ -0,0 +1,115 @@
package tui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
)
const (
header = "App do Dono - Instalador Cliente"
)
func (m Model) View() tea.View {
pad := strings.Repeat(" ", padding)
var body string
helpMsg := "ctrl+c: sair"
switch m.currentStep {
case StepCheckDocker:
body = m.viewCheckDocker()
case StepDockerInstall:
body = m.viewDockerInstall()
helpMsg = "qualquer tecla: sair"
case StepIPQuestion:
body = m.viewIPQuestion()
case StepServerConfig:
body = m.serverForm.View()
helpMsg = "tab: próximo campo • enter: confirmar • ctrl+c: sair"
case StepDatabaseConfig:
body = m.dbForm.View()
helpMsg = "tab: próximo campo • enter: confirmar • ctrl+c: sair"
case StepCertConfig:
body = m.certForm.View()
helpMsg = "tab: próximo campo • enter: confirmar • ctrl+c: sair"
}
help := HelpStyle.Render(helpMsg)
v := tea.NewView(fmt.Sprintf("\n%s%s\n\n%s\n\n%s%s\n",
pad, TitleStyle.Render(header),
body,
pad, help,
))
v.AltScreen = true
return v
}
func (m Model) viewCheckDocker() string {
pad := strings.Repeat(" ", padding)
barWidth := 40
filled := int(m.checkProgress * float64(barWidth))
empty := barWidth - filled
bar := ProgressFillStyle.Render(strings.Repeat("█", filled)) +
ProgressEmptyStyle.Render(strings.Repeat("░", empty))
percent := fmt.Sprintf(" %d%%", int(m.checkProgress*100))
var sb strings.Builder
sb.WriteString(pad + "Avaliando instalação do Docker...\n\n")
sb.WriteString(pad + bar + HelpStyle.Render(percent))
if m.checkDockerDone && m.checkProgress == 1 {
found := ""
if m.dockerInstalled {
found = "encontrado"
} else {
found = "não encontrado"
}
sb.WriteString("\n\n" + pad + fmt.Sprintf("Docker %s na máquina. Pressione qualquer tecla para seguir.", found))
}
return sb.String()
}
func (m Model) viewDockerInstall() string {
pad := strings.Repeat(" ", padding)
var sb strings.Builder
sb.WriteString(pad + "Nenhuma versão do Docker encontrada no sistema.\n")
sb.WriteString(pad + "Instale o Docker manualmente e execute este instalador novamente para concluir a configuração.")
return sb.String()
}
func (m Model) viewIPQuestion() string {
pad := strings.Repeat(" ", padding)
var options = []string{"Sim", "Não"}
var sb strings.Builder
sb.WriteString(pad + "Existe IP público disponível para a máquina?\n")
for i, opt := range options {
cursor := " "
text := ""
isSelected := m.cursor == i
if isSelected {
cursor = CursorStyle.Render("> ")
text = CursorStyle.Render(opt)
} else {
text = opt
}
sb.WriteString("\n" + pad + cursor + text)
}
return sb.String()
}

+ 0
- 28
tui/cmds.go View File

@ -1,28 +0,0 @@
package tui
import (
"os/exec"
tea "charm.land/bubbletea/v2"
)
// --- Messages ---
type DockerCheckedMsg struct{ Installed bool }
type DockerInstalledMsg struct{ Err error }
// --- Commands ---
func CheckDockerCmd() tea.Cmd {
return func() tea.Msg {
_, err := exec.LookPath("docker")
return DockerCheckedMsg{Installed: err == nil}
}
}
func InstallDockerCmd() tea.Cmd {
return func() tea.Msg {
err := exec.Command("sh", "-c", "curl -fsSL https://get.docker.com | sh").Run()
return DockerInstalledMsg{Err: err}
}
}

+ 0
- 1
tui/docker.go View File

@ -1 +0,0 @@
package tui

+ 0
- 25
tui/model.go View File

@ -1,25 +0,0 @@
package tui
import (
"charm.land/bubbles/v2/textinput"
tea "charm.land/bubbletea/v2"
)
type Model struct {
currentStep step
dockerInstalled bool
isPublicIP bool
inputs []textinput.Model
cursor int
err error
}
func InitialModel() Model {
return Model{
currentStep: StepCheckDocker,
}
}
func (m Model) Init() tea.Cmd {
return CheckDockerCmd()
}

+ 0
- 12
tui/styles.go View File

@ -1,12 +0,0 @@
package tui
import "charm.land/lipgloss/v2"
const padding = 2
var (
TitleStyle = lipgloss.NewStyle().Bold(true).Foreground(lipgloss.Color("#FAFAFA"))
HelpStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#626262"))
CursorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF75B7"))
ErrorStyle = lipgloss.NewStyle().Foreground(lipgloss.Color("#FF5555"))
)

+ 0
- 76
tui/update.go View File

@ -1,76 +0,0 @@
package tui
import tea "charm.land/bubbletea/v2"
func (m Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
// Global keys first — always handled regardless of step
if key, ok := msg.(tea.KeyPressMsg); ok {
switch key.String() {
case "ctrl+c", "q":
return m, tea.Quit
}
}
switch m.currentStep {
case StepCheckDocker:
return m.updateCheckDocker(msg)
case StepDockerInstall:
return m.updateDockerInstall(msg)
case StepIPQuestion:
return m.updateIPQuestion(msg)
case StepDone:
return m, tea.Quit
}
return m, nil
}
func (m Model) updateCheckDocker(msg tea.Msg) (tea.Model, tea.Cmd) {
if msg, ok := msg.(DockerCheckedMsg); ok {
m.dockerInstalled = msg.Installed
if msg.Installed {
m.currentStep = StepIPQuestion
} else {
m.currentStep = StepDockerInstall
m.cursor = 0 // reset cursor on enter
}
}
return m, nil
}
func (m Model) updateDockerInstall(msg tea.Msg) (tea.Model, tea.Cmd) {
const numOptions = 2
switch msg := msg.(type) {
case tea.KeyPressMsg:
switch msg.String() {
case "up", "k":
if m.cursor > 0 {
m.cursor--
}
case "down", "j":
if m.cursor < numOptions-1 {
m.cursor++
}
case "enter":
switch m.cursor {
case 0:
return m, InstallDockerCmd()
case 1:
return m, tea.Quit
}
}
case DockerInstalledMsg:
if msg.Err != nil {
m.err = msg.Err
} else {
m.currentStep = StepIPQuestion
}
}
return m, nil
}
func (m Model) updateIPQuestion(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}

+ 0
- 65
tui/view.go View File

@ -1,65 +0,0 @@
package tui
import (
"fmt"
"strings"
tea "charm.land/bubbletea/v2"
)
const header = "App do Dono - Instalador Cliente"
func (m Model) View() tea.View {
pad := strings.Repeat(" ", padding)
var body string
switch m.currentStep {
case StepCheckDocker:
body = m.viewCheckDocker()
case StepDockerInstall:
body = m.viewDockerInstall()
case StepIPQuestion:
body = m.viewIPQuestion()
}
help := HelpStyle.Render("Pressione q ou ctrl+c para sair")
return tea.NewView(fmt.Sprintf("\n%s%s\n\n%s\n\n%s%s\n",
pad, TitleStyle.Render(header),
body,
pad, help,
))
}
func (m Model) viewCheckDocker() string {
pad := strings.Repeat(" ", padding)
return pad + "Avaliando instalação do Docker. Aguarde..."
}
func (m Model) viewDockerInstall() string {
pad := strings.Repeat(" ", padding)
options := []string{"Instalar automaticamente", "Instalar manualmente"}
var sb strings.Builder
sb.WriteString(pad + "Nenhuma versão do Docker encontrada.\n")
sb.WriteString(pad + "Deseja:\n\n")
for i, opt := range options {
cursor := " "
if m.cursor == i {
cursor = CursorStyle.Render("> ")
}
sb.WriteString(pad + cursor + opt + "\n")
}
if m.err != nil {
sb.WriteString("\n" + pad + ErrorStyle.Render("Erro: "+m.err.Error()))
}
return sb.String()
}
func (m Model) viewIPQuestion() string {
pad := strings.Repeat(" ", padding)
return pad + "Existe IP público disponível para a máquina?"
}

Loading…
Cancel
Save