package tui import ( "context" "fmt" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "dbbackup/internal/config" "dbbackup/internal/logger" ) // Style definitions var ( titleStyle = lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("#FAFAFA")). Background(lipgloss.Color("#7D56F4")). Padding(0, 1) menuStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#626262")) menuSelectedStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF75B7")). Bold(true) infoStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#626262")) successStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#04B575")). Bold(true) errorStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#FF6B6B")). Bold(true) dbSelectorLabelStyle = lipgloss.NewStyle(). Foreground(lipgloss.Color("#57C7FF")). Bold(true) ) type dbTypeOption struct { label string value string } // MenuModel represents the simple menu state type MenuModel struct { choices []string cursor int config *config.Config logger logger.Logger quitting bool message string dbTypes []dbTypeOption dbTypeCursor int // Background operations ctx context.Context cancel context.CancelFunc } func NewMenuModel(cfg *config.Config, log logger.Logger) MenuModel { ctx, cancel := context.WithCancel(context.Background()) dbTypes := []dbTypeOption{ {label: "PostgreSQL", value: "postgres"}, {label: "MySQL / MariaDB", value: "mysql"}, } dbCursor := 0 if cfg.IsMySQL() { dbCursor = 1 } model := MenuModel{ choices: []string{ "Single Database Backup", "Sample Database Backup (with ratio)", "Cluster Backup (all databases)", "View Active Operations", "Show Operation History", "Database Status & Health Check", "Configuration Settings", "Clear Operation History", "Quit", }, config: cfg, logger: log, ctx: ctx, cancel: cancel, dbTypes: dbTypes, dbTypeCursor: dbCursor, } return model } // Init initializes the model func (m MenuModel) Init() tea.Cmd { return nil } // Update handles messages func (m MenuModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "ctrl+c", "q": if m.cancel != nil { m.cancel() } m.quitting = true return m, tea.Quit case "left", "h": if m.dbTypeCursor > 0 { m.dbTypeCursor-- m.applyDatabaseSelection() } case "right", "l": if m.dbTypeCursor < len(m.dbTypes)-1 { m.dbTypeCursor++ m.applyDatabaseSelection() } case "t": if len(m.dbTypes) > 0 { m.dbTypeCursor = (m.dbTypeCursor + 1) % len(m.dbTypes) m.applyDatabaseSelection() } case "up", "k": if m.cursor > 0 { m.cursor-- } case "down", "j": if m.cursor < len(m.choices)-1 { m.cursor++ } case "enter", " ": switch m.cursor { case 0: // Single Database Backup return m.handleSingleBackup() case 1: // Sample Database Backup return m.handleSampleBackup() case 2: // Cluster Backup return m.handleClusterBackup() case 3: // View Active Operations return m.handleViewOperations() case 4: // Show Operation History return m.handleOperationHistory() case 5: // Database Status return m.handleStatus() case 6: // Settings return m.handleSettings() case 7: // Clear History m.message = "šŸ—‘ļø History cleared" case 8: // Quit if m.cancel != nil { m.cancel() } m.quitting = true return m, tea.Quit } } } return m, nil } // View renders the simple menu func (m MenuModel) View() string { if m.quitting { return "Thanks for using DB Backup Tool!\n" } var s string // Header header := titleStyle.Render("šŸ—„ļø Database Backup Tool - Interactive Menu") s += fmt.Sprintf("\n%s\n\n", header) if len(m.dbTypes) > 0 { options := make([]string, len(m.dbTypes)) for i, opt := range m.dbTypes { if m.dbTypeCursor == i { options[i] = menuSelectedStyle.Render(opt.label) } else { options[i] = menuStyle.Render(opt.label) } } selector := fmt.Sprintf("Target Engine: %s", strings.Join(options, menuStyle.Render(" | "))) s += dbSelectorLabelStyle.Render(selector) + "\n" hint := infoStyle.Render("Switch with ←/→ or t • Cluster backup requires PostgreSQL") s += hint + "\n\n" } // Database info dbInfo := infoStyle.Render(fmt.Sprintf("Database: %s@%s:%d (%s)", m.config.User, m.config.Host, m.config.Port, m.config.DisplayDatabaseType())) s += fmt.Sprintf("%s\n\n", dbInfo) // Menu items for i, choice := range m.choices { cursor := " " if m.cursor == i { cursor = ">" s += menuSelectedStyle.Render(fmt.Sprintf("%s %s", cursor, choice)) } else { s += menuStyle.Render(fmt.Sprintf("%s %s", cursor, choice)) } s += "\n" } // Message area if m.message != "" { s += "\n" + m.message + "\n" } // Footer footer := infoStyle.Render("\nāŒØļø Press ↑/↓ to navigate • Enter to select • q to quit") s += footer return s } // handleSingleBackup opens database selector for single backup func (m MenuModel) handleSingleBackup() (tea.Model, tea.Cmd) { selector := NewDatabaseSelector(m.config, m.logger, m, "šŸ—„ļø Single Database Backup", "single") return selector, selector.Init() } // handleSampleBackup opens database selector for sample backup func (m MenuModel) handleSampleBackup() (tea.Model, tea.Cmd) { selector := NewDatabaseSelector(m.config, m.logger, m, "šŸ“Š Sample Database Backup", "sample") return selector, selector.Init() } // handleClusterBackup shows confirmation and executes cluster backup func (m MenuModel) handleClusterBackup() (tea.Model, tea.Cmd) { if !m.config.IsPostgreSQL() { m.message = errorStyle.Render("āŒ Cluster backup is available only for PostgreSQL targets") return m, nil } confirm := NewConfirmationModel(m.config, m.logger, m, "šŸ—„ļø Cluster Backup", "This will backup ALL databases in the cluster. Continue?") return confirm, nil } // handleViewOperations shows active operations func (m MenuModel) handleViewOperations() (tea.Model, tea.Cmd) { ops := NewOperationsView(m.config, m.logger, m) return ops, nil } // handleOperationHistory shows operation history func (m MenuModel) handleOperationHistory() (tea.Model, tea.Cmd) { history := NewHistoryView(m.config, m.logger, m) return history, nil } // handleStatus shows database status func (m MenuModel) handleStatus() (tea.Model, tea.Cmd) { status := NewStatusView(m.config, m.logger, m) return status, status.Init() } // handleSettings opens settings func (m MenuModel) handleSettings() (tea.Model, tea.Cmd) { // Create and return the settings model settingsModel := NewSettingsModel(m.config, m.logger, m) return settingsModel, nil } func (m *MenuModel) applyDatabaseSelection() { if m == nil || len(m.dbTypes) == 0 { return } if m.dbTypeCursor < 0 || m.dbTypeCursor >= len(m.dbTypes) { return } selection := m.dbTypes[m.dbTypeCursor] if err := m.config.SetDatabaseType(selection.value); err != nil { m.message = errorStyle.Render(fmt.Sprintf("āŒ %v", err)) return } // Refresh default port if unchanged if m.config.Port == 0 { m.config.Port = m.config.GetDefaultPort() } m.message = successStyle.Render(fmt.Sprintf("šŸ”€ Target database set to %s", m.config.DisplayDatabaseType())) if m.logger != nil { m.logger.Info("updated target database type", "type", m.config.DatabaseType, "port", m.config.Port) } } // RunInteractiveMenu starts the simple TUI func RunInteractiveMenu(cfg *config.Config, log logger.Logger) error { m := NewMenuModel(cfg, log) p := tea.NewProgram(m) if _, err := p.Run(); err != nil { return fmt.Errorf("error running interactive menu: %w", err) } return nil }