827 lines
22 KiB
Go
827 lines
22 KiB
Go
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
|
|
}
|