No One Can Stop Me Now
This commit is contained in:
826
services/server-manager/internal/tui/app.go
Normal file
826
services/server-manager/internal/tui/app.go
Normal file
@@ -0,0 +1,826 @@
|
||||
package tui
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"strings"
|
||||
|
||||
"github.com/charmbracelet/bubbles/textinput"
|
||||
tea "github.com/charmbracelet/bubbletea"
|
||||
"github.com/charmbracelet/lipgloss"
|
||||
)
|
||||
|
||||
// ── View IDs ────────────────────────────────────────────────────────
|
||||
|
||||
type ViewID int
|
||||
|
||||
const (
|
||||
ViewMain ViewID = iota
|
||||
ViewDeps
|
||||
ViewDepsInstall
|
||||
ViewModules
|
||||
ViewModuleToggle
|
||||
ViewSettings
|
||||
ViewSettingsSection
|
||||
ViewSettingsEdit
|
||||
ViewUsers
|
||||
ViewUsersCreate
|
||||
ViewUsersReset
|
||||
ViewService
|
||||
ViewDNS
|
||||
ViewDNSBuild
|
||||
ViewDNSManage
|
||||
ViewDNSZones
|
||||
ViewDNSZoneEdit
|
||||
ViewDeploy
|
||||
ViewConfirm
|
||||
ViewResult
|
||||
)
|
||||
|
||||
// ── Styles ──────────────────────────────────────────────────────────
|
||||
|
||||
var (
|
||||
colorRed = lipgloss.Color("#ef4444")
|
||||
colorGreen = lipgloss.Color("#22c55e")
|
||||
colorYellow = lipgloss.Color("#eab308")
|
||||
colorBlue = lipgloss.Color("#6366f1")
|
||||
colorCyan = lipgloss.Color("#06b6d4")
|
||||
colorMagenta = lipgloss.Color("#a855f7")
|
||||
colorDim = lipgloss.Color("#6b7280")
|
||||
colorWhite = lipgloss.Color("#f9fafb")
|
||||
colorSurface = lipgloss.Color("#1e1e2e")
|
||||
colorBorder = lipgloss.Color("#3b3b5c")
|
||||
|
||||
styleBanner = lipgloss.NewStyle().
|
||||
Foreground(colorRed).
|
||||
Bold(true)
|
||||
|
||||
styleTitle = lipgloss.NewStyle().
|
||||
Foreground(colorCyan).
|
||||
Bold(true).
|
||||
PaddingLeft(2)
|
||||
|
||||
styleSubtitle = lipgloss.NewStyle().
|
||||
Foreground(colorDim).
|
||||
PaddingLeft(2)
|
||||
|
||||
styleMenuItem = lipgloss.NewStyle().
|
||||
PaddingLeft(4)
|
||||
|
||||
styleSelected = lipgloss.NewStyle().
|
||||
Foreground(colorBlue).
|
||||
Bold(true).
|
||||
PaddingLeft(2)
|
||||
|
||||
styleNormal = lipgloss.NewStyle().
|
||||
Foreground(colorWhite).
|
||||
PaddingLeft(4)
|
||||
|
||||
styleKey = lipgloss.NewStyle().
|
||||
Foreground(colorCyan).
|
||||
Bold(true)
|
||||
|
||||
styleSuccess = lipgloss.NewStyle().
|
||||
Foreground(colorGreen)
|
||||
|
||||
styleError = lipgloss.NewStyle().
|
||||
Foreground(colorRed)
|
||||
|
||||
styleWarning = lipgloss.NewStyle().
|
||||
Foreground(colorYellow)
|
||||
|
||||
styleDim = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
|
||||
styleStatusOK = lipgloss.NewStyle().
|
||||
Foreground(colorGreen).
|
||||
Bold(true)
|
||||
|
||||
styleStatusBad = lipgloss.NewStyle().
|
||||
Foreground(colorRed).
|
||||
Bold(true)
|
||||
|
||||
styleBox = lipgloss.NewStyle().
|
||||
Border(lipgloss.RoundedBorder()).
|
||||
BorderForeground(colorBorder).
|
||||
Padding(1, 2)
|
||||
|
||||
styleHR = lipgloss.NewStyle().
|
||||
Foreground(colorDim)
|
||||
)
|
||||
|
||||
// ── Menu Item ───────────────────────────────────────────────────────
|
||||
|
||||
type MenuItem struct {
|
||||
Key string
|
||||
Label string
|
||||
Desc string
|
||||
View ViewID
|
||||
}
|
||||
|
||||
// ── Messages ────────────────────────────────────────────────────────
|
||||
|
||||
type ResultMsg struct {
|
||||
Title string
|
||||
Lines []string
|
||||
IsError bool
|
||||
}
|
||||
|
||||
type ConfirmMsg struct {
|
||||
Prompt string
|
||||
OnConfirm func() tea.Cmd
|
||||
}
|
||||
|
||||
type OutputLineMsg string
|
||||
type DoneMsg struct{ Err error }
|
||||
|
||||
// ── App Model ───────────────────────────────────────────────────────
|
||||
|
||||
type App struct {
|
||||
width, height int
|
||||
|
||||
// Navigation
|
||||
view ViewID
|
||||
viewStack []ViewID
|
||||
cursor int
|
||||
|
||||
// Main menu
|
||||
mainMenu []MenuItem
|
||||
|
||||
// Dynamic content
|
||||
listItems []ListItem
|
||||
listTitle string
|
||||
sectionKeys []string
|
||||
|
||||
// Settings
|
||||
settingsSections []string
|
||||
settingsSection string
|
||||
settingsKeys []string
|
||||
settingsVals []string
|
||||
|
||||
// Text input
|
||||
textInput textinput.Model
|
||||
inputLabel string
|
||||
inputField string
|
||||
inputs []textinput.Model
|
||||
labels []string
|
||||
focusIdx int
|
||||
|
||||
// Result / output
|
||||
resultTitle string
|
||||
resultLines []string
|
||||
resultIsErr bool
|
||||
outputLines []string
|
||||
outputDone bool
|
||||
outputCh chan tea.Msg
|
||||
progressStep int
|
||||
progressTotal int
|
||||
progressLabel string
|
||||
|
||||
// Confirm
|
||||
confirmPrompt string
|
||||
confirmAction func() tea.Cmd
|
||||
|
||||
// Config path
|
||||
autarchDir string
|
||||
}
|
||||
|
||||
type ListItem struct {
|
||||
Name string
|
||||
Status string
|
||||
Enabled bool
|
||||
Extra string
|
||||
}
|
||||
|
||||
func NewApp() App {
|
||||
ti := textinput.New()
|
||||
ti.CharLimit = 256
|
||||
|
||||
app := App{
|
||||
view: ViewMain,
|
||||
autarchDir: findAutarchDir(),
|
||||
textInput: ti,
|
||||
mainMenu: []MenuItem{
|
||||
{Key: "1", Label: "Deploy AUTARCH", Desc: "Clone from GitHub, setup dirs, venv, deps, permissions, systemd", View: ViewDeploy},
|
||||
{Key: "2", Label: "Dependencies", Desc: "Install & manage system packages, Python venv, pip, npm", View: ViewDeps},
|
||||
{Key: "3", Label: "Modules", Desc: "List, enable, or disable AUTARCH Python modules", View: ViewModules},
|
||||
{Key: "4", Label: "Settings", Desc: "Edit autarch_settings.conf (all 14+ sections)", View: ViewSettings},
|
||||
{Key: "5", Label: "Users", Desc: "Create users, reset passwords, manage web credentials", View: ViewUsers},
|
||||
{Key: "6", Label: "Services", Desc: "Start, stop, restart AUTARCH web & background daemons", View: ViewService},
|
||||
{Key: "7", Label: "DNS Server", Desc: "Build, configure, and manage the AUTARCH DNS server", View: ViewDNS},
|
||||
{Key: "q", Label: "Quit", Desc: "Exit the server manager", View: ViewMain},
|
||||
},
|
||||
}
|
||||
return app
|
||||
}
|
||||
|
||||
func (a App) Init() tea.Cmd {
|
||||
return nil
|
||||
}
|
||||
|
||||
// waitForOutput returns a Cmd that reads the next message from the output channel.
|
||||
// This creates the streaming chain: OutputLineMsg → waitForOutput → OutputLineMsg → ...
|
||||
func (a App) waitForOutput() tea.Cmd {
|
||||
ch := a.outputCh
|
||||
if ch == nil {
|
||||
return nil
|
||||
}
|
||||
return func() tea.Msg {
|
||||
msg, ok := <-ch
|
||||
if !ok {
|
||||
return DoneMsg{}
|
||||
}
|
||||
return msg
|
||||
}
|
||||
}
|
||||
|
||||
// ── Update ──────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
|
||||
switch msg := msg.(type) {
|
||||
|
||||
case tea.WindowSizeMsg:
|
||||
a.width = msg.Width
|
||||
a.height = msg.Height
|
||||
return a, nil
|
||||
|
||||
case ResultMsg:
|
||||
a.pushView(ViewResult)
|
||||
a.resultTitle = msg.Title
|
||||
a.resultLines = msg.Lines
|
||||
a.resultIsErr = msg.IsError
|
||||
return a, nil
|
||||
|
||||
case OutputLineMsg:
|
||||
a.outputLines = append(a.outputLines, string(msg))
|
||||
return a, a.waitForOutput()
|
||||
|
||||
case ProgressMsg:
|
||||
a.progressStep = msg.Step
|
||||
a.progressTotal = msg.Total
|
||||
a.progressLabel = msg.Label
|
||||
return a, a.waitForOutput()
|
||||
|
||||
case DoneMsg:
|
||||
a.outputDone = true
|
||||
a.outputCh = nil
|
||||
if msg.Err != nil {
|
||||
a.outputLines = append(a.outputLines, "", styleError.Render("Error: "+msg.Err.Error()))
|
||||
}
|
||||
a.outputLines = append(a.outputLines, "", styleDim.Render("Press any key to continue..."))
|
||||
return a, nil
|
||||
|
||||
case depsLoadedMsg:
|
||||
a.listItems = msg.items
|
||||
return a, nil
|
||||
case modulesLoadedMsg:
|
||||
a.listItems = msg.items
|
||||
return a, nil
|
||||
case settingsLoadedMsg:
|
||||
a.settingsSections = msg.sections
|
||||
return a, nil
|
||||
case dnsZonesMsg:
|
||||
a.listItems = msg.items
|
||||
return a, nil
|
||||
|
||||
case tea.KeyMsg:
|
||||
return a.handleKey(msg)
|
||||
}
|
||||
|
||||
// Update text inputs if active
|
||||
if a.isInputView() {
|
||||
return a.updateInputs(msg)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) isInputView() bool {
|
||||
return a.view == ViewUsersCreate || a.view == ViewUsersReset ||
|
||||
a.view == ViewSettingsEdit || a.view == ViewDNSZoneEdit
|
||||
}
|
||||
|
||||
func (a App) handleKey(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
|
||||
key := msg.String()
|
||||
|
||||
// Global keys
|
||||
switch key {
|
||||
case "ctrl+c":
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// Input views get special handling
|
||||
if a.isInputView() {
|
||||
return a.handleInputKey(msg)
|
||||
}
|
||||
|
||||
// Output view (streaming)
|
||||
if a.view == ViewDepsInstall || a.view == ViewDNSBuild {
|
||||
if a.outputDone {
|
||||
a.popView()
|
||||
a.progressStep = 0
|
||||
a.progressTotal = 0
|
||||
a.progressLabel = ""
|
||||
// Reload the parent view's data
|
||||
switch a.view {
|
||||
case ViewDeps:
|
||||
return a, a.loadDepsStatus()
|
||||
case ViewDNS:
|
||||
return a, nil
|
||||
case ViewDeploy:
|
||||
return a, nil
|
||||
}
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Result view
|
||||
if a.view == ViewResult {
|
||||
a.popView()
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// Confirm view
|
||||
if a.view == ViewConfirm {
|
||||
switch key {
|
||||
case "y", "Y":
|
||||
if a.confirmAction != nil {
|
||||
cmd := a.confirmAction()
|
||||
a.popView()
|
||||
return a, cmd
|
||||
}
|
||||
a.popView()
|
||||
case "n", "N", "esc":
|
||||
a.popView()
|
||||
}
|
||||
return a, nil
|
||||
}
|
||||
|
||||
// List navigation
|
||||
switch key {
|
||||
case "up", "k":
|
||||
if a.cursor > 0 {
|
||||
a.cursor--
|
||||
}
|
||||
return a, nil
|
||||
case "down", "j":
|
||||
max := a.maxCursor()
|
||||
if a.cursor < max {
|
||||
a.cursor++
|
||||
}
|
||||
return a, nil
|
||||
case "esc":
|
||||
if len(a.viewStack) > 0 {
|
||||
a.popView()
|
||||
}
|
||||
return a, nil
|
||||
case "q":
|
||||
if a.view == ViewMain {
|
||||
return a, tea.Quit
|
||||
}
|
||||
if len(a.viewStack) > 0 {
|
||||
a.popView()
|
||||
return a, nil
|
||||
}
|
||||
return a, tea.Quit
|
||||
}
|
||||
|
||||
// View-specific handling
|
||||
switch a.view {
|
||||
case ViewMain:
|
||||
return a.handleMainMenu(key)
|
||||
case ViewDeps:
|
||||
return a.handleDepsMenu(key)
|
||||
case ViewModules:
|
||||
return a.handleModulesMenu(key)
|
||||
case ViewModuleToggle:
|
||||
return a.handleModuleToggle(key)
|
||||
case ViewSettings:
|
||||
return a.handleSettingsMenu(key)
|
||||
case ViewSettingsSection:
|
||||
return a.handleSettingsSection(key)
|
||||
case ViewUsers:
|
||||
return a.handleUsersMenu(key)
|
||||
case ViewService:
|
||||
return a.handleServiceMenu(key)
|
||||
case ViewDeploy:
|
||||
return a.handleDeployMenu(key)
|
||||
case ViewDNS:
|
||||
return a.handleDNSMenu(key)
|
||||
case ViewDNSManage:
|
||||
return a.handleDNSManageMenu(key)
|
||||
case ViewDNSZones:
|
||||
return a.handleDNSZonesMenu(key)
|
||||
}
|
||||
|
||||
return a, nil
|
||||
}
|
||||
|
||||
func (a App) maxCursor() int {
|
||||
switch a.view {
|
||||
case ViewMain:
|
||||
return len(a.mainMenu) - 1
|
||||
case ViewModules, ViewModuleToggle:
|
||||
return len(a.listItems) - 1
|
||||
case ViewSettings:
|
||||
return len(a.settingsSections) - 1
|
||||
case ViewSettingsSection:
|
||||
return len(a.settingsKeys) - 1
|
||||
case ViewDNSZones:
|
||||
return len(a.listItems) - 1
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
// ── Navigation ──────────────────────────────────────────────────────
|
||||
|
||||
func (a *App) pushView(v ViewID) {
|
||||
a.viewStack = append(a.viewStack, a.view)
|
||||
a.view = v
|
||||
a.cursor = 0
|
||||
}
|
||||
|
||||
func (a *App) popView() {
|
||||
if len(a.viewStack) > 0 {
|
||||
a.view = a.viewStack[len(a.viewStack)-1]
|
||||
a.viewStack = a.viewStack[:len(a.viewStack)-1]
|
||||
a.cursor = 0
|
||||
}
|
||||
}
|
||||
|
||||
// ── View Rendering ──────────────────────────────────────────────────
|
||||
|
||||
func (a App) View() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(a.renderBanner())
|
||||
b.WriteString("\n")
|
||||
|
||||
switch a.view {
|
||||
case ViewMain:
|
||||
b.WriteString(a.renderMainMenu())
|
||||
case ViewDeploy:
|
||||
b.WriteString(a.renderDeployMenu())
|
||||
case ViewDeps:
|
||||
b.WriteString(a.renderDepsMenu())
|
||||
case ViewDepsInstall:
|
||||
b.WriteString(a.renderOutput("Installing Dependencies"))
|
||||
case ViewModules:
|
||||
b.WriteString(a.renderModulesList())
|
||||
case ViewModuleToggle:
|
||||
b.WriteString(a.renderModulesList())
|
||||
case ViewSettings:
|
||||
b.WriteString(a.renderSettingsSections())
|
||||
case ViewSettingsSection:
|
||||
b.WriteString(a.renderSettingsKeys())
|
||||
case ViewSettingsEdit:
|
||||
b.WriteString(a.renderSettingsEditForm())
|
||||
case ViewUsers:
|
||||
b.WriteString(a.renderUsersMenu())
|
||||
case ViewUsersCreate:
|
||||
b.WriteString(a.renderUserForm("Create New User"))
|
||||
case ViewUsersReset:
|
||||
b.WriteString(a.renderUserForm("Reset Password"))
|
||||
case ViewService:
|
||||
b.WriteString(a.renderServiceMenu())
|
||||
case ViewDNS:
|
||||
b.WriteString(a.renderDNSMenu())
|
||||
case ViewDNSBuild:
|
||||
b.WriteString(a.renderOutput("Building DNS Server"))
|
||||
case ViewDNSManage:
|
||||
b.WriteString(a.renderDNSManageMenu())
|
||||
case ViewDNSZones:
|
||||
b.WriteString(a.renderDNSZones())
|
||||
case ViewDNSZoneEdit:
|
||||
b.WriteString(a.renderDNSZoneForm())
|
||||
case ViewConfirm:
|
||||
b.WriteString(a.renderConfirm())
|
||||
case ViewResult:
|
||||
b.WriteString(a.renderResult())
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderStatusBar())
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Banner ──────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderBanner() string {
|
||||
banner := `
|
||||
▄▄▄ █ ██ ▄▄▄█████▓ ▄▄▄ ██▀███ ▄████▄ ██░ ██
|
||||
▒████▄ ██ ▓██▒▓ ██▒ ▓▒▒████▄ ▓██ ▒ ██▒▒██▀ ▀█ ▓██░ ██▒
|
||||
▒██ ▀█▄ ▓██ ▒██░▒ ▓██░ ▒░▒██ ▀█▄ ▓██ ░▄█ ▒▒▓█ ▄ ▒██▀▀██░
|
||||
░██▄▄▄▄██ ▓▓█ ░██░░ ▓██▓ ░ ░██▄▄▄▄██ ▒██▀▀█▄ ▒▓▓▄ ▄██▒░▓█ ░██
|
||||
▓█ ▓██▒▒▒█████▓ ▒██▒ ░ ▓█ ▓██▒░██▓ ▒██▒▒ ▓███▀ ░░▓█▒░██▓
|
||||
▒▒ ▓▒█░░▒▓▒ ▒ ▒ ▒ ░░ ▒▒ ▓▒█░░ ▒▓ ░▒▓░░ ░▒ ▒ ░ ▒ ░░▒░▒
|
||||
▒ ▒▒ ░░░▒░ ░ ░ ░ ▒ ▒▒ ░ ░▒ ░ ▒░ ░ ▒ ▒ ░▒░ ░
|
||||
░ ▒ ░░░ ░ ░ ░ ░ ▒ ░░ ░ ░ ░ ░░ ░
|
||||
░ ░ ░ ░ ░ ░ ░ ░ ░ ░ ░`
|
||||
|
||||
title := lipgloss.NewStyle().
|
||||
Foreground(colorCyan).
|
||||
Bold(true).
|
||||
Align(lipgloss.Center).
|
||||
Render("S E R V E R M A N A G E R v1.0")
|
||||
|
||||
sub := styleDim.Render(" darkHal Security Group & Setec Security Labs")
|
||||
|
||||
// Live service status bar
|
||||
statusLine := a.renderServiceStatusBar()
|
||||
|
||||
return styleBanner.Render(banner) + "\n" + title + "\n" + sub + "\n" + statusLine + "\n"
|
||||
}
|
||||
|
||||
func (a App) renderServiceStatusBar() string {
|
||||
webStatus, webUp := getProcessStatus("autarch-web", "autarch_web.py")
|
||||
dnsStatus, dnsUp := getProcessStatus("autarch-dns", "autarch-dns")
|
||||
|
||||
webInd := styleStatusBad.Render("○")
|
||||
if webUp {
|
||||
webInd = styleStatusOK.Render("●")
|
||||
}
|
||||
dnsInd := styleStatusBad.Render("○")
|
||||
if dnsUp {
|
||||
dnsInd = styleStatusOK.Render("●")
|
||||
}
|
||||
|
||||
_ = webStatus
|
||||
_ = dnsStatus
|
||||
|
||||
return styleDim.Render(" ") +
|
||||
webInd + styleDim.Render(" Web ") +
|
||||
dnsInd + styleDim.Render(" DNS")
|
||||
}
|
||||
|
||||
func (a App) renderHR() string {
|
||||
w := a.width
|
||||
if w < 10 {
|
||||
w = 66
|
||||
}
|
||||
if w > 80 {
|
||||
w = 80
|
||||
}
|
||||
return styleHR.Render(strings.Repeat("─", w-4)) + "\n"
|
||||
}
|
||||
|
||||
// ── Main Menu ───────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderMainMenu() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("MAIN MENU"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, item := range a.mainMenu {
|
||||
cursor := " "
|
||||
if i == a.cursor {
|
||||
cursor = styleSelected.Render("▸ ")
|
||||
label := styleKey.Render("["+item.Key+"]") + " " +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Bold(true).Render(item.Label)
|
||||
desc := styleDim.Render(" " + item.Desc)
|
||||
b.WriteString(cursor + label + "\n")
|
||||
b.WriteString(" " + desc + "\n")
|
||||
} else {
|
||||
label := styleDim.Render("["+item.Key+"]") + " " +
|
||||
lipgloss.NewStyle().Foreground(colorWhite).Render(item.Label)
|
||||
b.WriteString(cursor + " " + label + "\n")
|
||||
}
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Confirm ─────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderConfirm() string {
|
||||
var b strings.Builder
|
||||
b.WriteString(styleTitle.Render("CONFIRM"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleWarning.Render(" " + a.confirmPrompt))
|
||||
b.WriteString("\n\n")
|
||||
b.WriteString(styleDim.Render(" [Y] Yes [N] No"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Result ──────────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderResult() string {
|
||||
var b strings.Builder
|
||||
|
||||
title := a.resultTitle
|
||||
if a.resultIsErr {
|
||||
b.WriteString(styleError.Render(" " + title))
|
||||
} else {
|
||||
b.WriteString(styleSuccess.Render(" " + title))
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for _, line := range a.resultLines {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" Press any key to continue..."))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Streaming Output ────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderOutput(title string) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render(title))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
|
||||
// Progress bar
|
||||
if a.progressTotal > 0 && !a.outputDone {
|
||||
pct := float64(a.progressStep) / float64(a.progressTotal)
|
||||
barWidth := 40
|
||||
filled := int(pct * float64(barWidth))
|
||||
if filled > barWidth {
|
||||
filled = barWidth
|
||||
}
|
||||
bar := strings.Repeat("█", filled) + strings.Repeat("░", barWidth-filled)
|
||||
pctStr := fmt.Sprintf("%3.0f%%", pct*100)
|
||||
|
||||
b.WriteString(" " + styleKey.Render("["+bar+"]") + " " +
|
||||
styleWarning.Render(pctStr) + " " +
|
||||
styleDim.Render(fmt.Sprintf("Step %d/%d: %s", a.progressStep, a.progressTotal, a.progressLabel)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
}
|
||||
b.WriteString("\n")
|
||||
|
||||
// Show last N lines that fit the screen
|
||||
maxLines := a.height - 22
|
||||
if maxLines < 10 {
|
||||
maxLines = 20
|
||||
}
|
||||
start := 0
|
||||
if len(a.outputLines) > maxLines {
|
||||
start = len(a.outputLines) - maxLines
|
||||
}
|
||||
for _, line := range a.outputLines[start:] {
|
||||
b.WriteString(" " + line + "\n")
|
||||
}
|
||||
|
||||
if !a.outputDone {
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" Working..."))
|
||||
}
|
||||
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Status Bar ──────────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderStatusBar() string {
|
||||
nav := styleDim.Render(" ↑↓ navigate")
|
||||
esc := styleDim.Render(" esc back")
|
||||
quit := styleDim.Render(" q quit")
|
||||
|
||||
path := ""
|
||||
for _, v := range a.viewStack {
|
||||
path += viewName(v) + " > "
|
||||
}
|
||||
path += viewName(a.view)
|
||||
|
||||
left := styleDim.Render(" " + path)
|
||||
right := nav + esc + quit
|
||||
|
||||
gap := a.width - lipgloss.Width(left) - lipgloss.Width(right)
|
||||
if gap < 1 {
|
||||
gap = 1
|
||||
}
|
||||
|
||||
return "\n" + styleHR.Render(strings.Repeat("─", clamp(a.width-4, 20, 80))) + "\n" +
|
||||
left + strings.Repeat(" ", gap) + right + "\n"
|
||||
}
|
||||
|
||||
func viewName(v ViewID) string {
|
||||
names := map[ViewID]string{
|
||||
ViewMain: "Main",
|
||||
ViewDeps: "Dependencies",
|
||||
ViewDepsInstall: "Install",
|
||||
ViewModules: "Modules",
|
||||
ViewModuleToggle: "Toggle",
|
||||
ViewSettings: "Settings",
|
||||
ViewSettingsSection: "Section",
|
||||
ViewSettingsEdit: "Edit",
|
||||
ViewUsers: "Users",
|
||||
ViewUsersCreate: "Create",
|
||||
ViewUsersReset: "Reset",
|
||||
ViewService: "Services",
|
||||
ViewDNS: "DNS",
|
||||
ViewDNSBuild: "Build",
|
||||
ViewDNSManage: "Manage",
|
||||
ViewDNSZones: "Zones",
|
||||
ViewDeploy: "Deploy",
|
||||
ViewDNSZoneEdit: "Edit Zone",
|
||||
ViewConfirm: "Confirm",
|
||||
ViewResult: "Result",
|
||||
}
|
||||
if n, ok := names[v]; ok {
|
||||
return n
|
||||
}
|
||||
return "?"
|
||||
}
|
||||
|
||||
// ── User Form Rendering ─────────────────────────────────────────────
|
||||
|
||||
func (a App) renderUserForm(title string) string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render(title))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, label := range a.labels {
|
||||
prefix := " "
|
||||
if i == a.focusIdx {
|
||||
prefix = styleSelected.Render("▸ ")
|
||||
}
|
||||
b.WriteString(prefix + styleDim.Render(label+": "))
|
||||
b.WriteString(a.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Settings Edit Form ──────────────────────────────────────────────
|
||||
|
||||
func (a App) renderSettingsEditForm() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render(fmt.Sprintf("Edit [%s]", a.settingsSection)))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, label := range a.labels {
|
||||
prefix := " "
|
||||
if i == a.focusIdx {
|
||||
prefix = styleSelected.Render("▸ ")
|
||||
}
|
||||
b.WriteString(prefix + styleKey.Render(label) + " = ")
|
||||
b.WriteString(a.inputs[i].View())
|
||||
b.WriteString("\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" tab next | enter save all | esc cancel"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── DNS Zone Form ───────────────────────────────────────────────────
|
||||
|
||||
func (a App) renderDNSZoneForm() string {
|
||||
var b strings.Builder
|
||||
|
||||
b.WriteString(styleTitle.Render("Create DNS Zone"))
|
||||
b.WriteString("\n")
|
||||
b.WriteString(a.renderHR())
|
||||
b.WriteString("\n")
|
||||
|
||||
for i, label := range a.labels {
|
||||
prefix := " "
|
||||
if i == a.focusIdx {
|
||||
prefix = styleSelected.Render("▸ ")
|
||||
}
|
||||
b.WriteString(prefix + styleDim.Render(label+": "))
|
||||
b.WriteString(a.inputs[i].View())
|
||||
b.WriteString("\n\n")
|
||||
}
|
||||
|
||||
b.WriteString("\n")
|
||||
b.WriteString(styleDim.Render(" tab next field | enter submit | esc cancel"))
|
||||
b.WriteString("\n")
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// ── Helpers ─────────────────────────────────────────────────────────
|
||||
|
||||
func clamp(v, lo, hi int) int {
|
||||
if v < lo {
|
||||
return lo
|
||||
}
|
||||
if v > hi {
|
||||
return hi
|
||||
}
|
||||
return v
|
||||
}
|
||||
Reference in New Issue
Block a user