diff --git a/cmd/overlock/tui/tui.go b/cmd/overlock/tui/tui.go index bf1263b..b2b3297 100644 --- a/cmd/overlock/tui/tui.go +++ b/cmd/overlock/tui/tui.go @@ -1,24 +1,29 @@ package tui import ( + "context" "fmt" "os" tea "github.com/charmbracelet/bubbletea" "github.com/web-seven/overlock/internal/tui" + "go.uber.org/zap" ) // TUICmd represents the TUI command type TUICmd struct{} // Run executes the TUI command -func (c *TUICmd) Run() error { +func (c *TUICmd) Run(ctx context.Context, logger *zap.SugaredLogger) error { // Create the app model - model := tui.NewAppModel() + model := tui.NewAppModel(logger) // Create the Bubble Tea program p := tea.NewProgram(model, tea.WithAltScreen()) + // Set the program reference for log messages + model.SetProgram(p) + // Run the program finalModel, err := p.Run() if err != nil { diff --git a/internal/tui/app.go b/internal/tui/app.go index e79ee4a..ea026c6 100644 --- a/internal/tui/app.go +++ b/internal/tui/app.go @@ -8,30 +8,42 @@ import ( tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" "github.com/web-seven/overlock/cmd/overlock/version" + "go.uber.org/zap" ) const ( - menuWidth = 30 + menuWidth = 19 // longest menu item "Configuration" (13) + 6 + rightSidebarWidth = 50 ) // AppModel represents the main TUI application model type AppModel struct { - list list.Model - viewport viewport.Model - ready bool - width int - height int - styles Styles - quitting bool - selectedItem string + list list.Model + viewport viewport.Model + rightSidebar viewport.Model + ready bool + width int + height int + styles Styles + quitting bool + selectedItem string + envModel *EnvironmentModel + logger *zap.SugaredLogger + logBuffer *LogBuffer + tuiLogger *zap.SugaredLogger + inSubView bool + showRightSidebar bool + footerShortcuts string } // NewAppModel creates a new app model with the given items -func NewAppModel() *AppModel { +func NewAppModel(logger *zap.SugaredLogger) *AppModel { items := DefaultMenuItems() // Create list with default delegate delegate := list.NewDefaultDelegate() + delegate.ShowDescription = false + delegate.SetSpacing(1) l := list.New(items, delegate, 0, 0) l.Title = "" l.SetShowStatusBar(false) @@ -39,11 +51,23 @@ func NewAppModel() *AppModel { l.SetShowHelp(false) l.SetShowTitle(false) + styles := NewStyles() + + // Create log buffer and TUI logger + logBuffer := NewLogBuffer() + tuiLogger := CreateTUILogger(logBuffer) + return &AppModel{ - list: l, - styles: NewStyles(), - quitting: false, - ready: false, + list: l, + styles: styles, + quitting: false, + ready: false, + logger: logger, + logBuffer: logBuffer, + tuiLogger: tuiLogger, + inSubView: false, + showRightSidebar: false, + footerShortcuts: "[a] Actions [/] Search [?] Help [q] Quit", } } @@ -65,7 +89,13 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { footerHeight := lipgloss.Height(m.footerView()) verticalMarginHeight := headerHeight + footerHeight + // Calculate content width based on whether logs are shown + // Without logs: │menu(menuWidth-2)│content│ = 3 borders + // With logs: │menu(menuWidth-2)│content│logs(rightSidebarWidth-2)│ = 4 borders contentWidth := m.width - menuWidth - 1 + if m.showRightSidebar { + contentWidth = m.width - menuWidth - rightSidebarWidth + } if contentWidth < 20 { contentWidth = 20 } @@ -77,19 +107,72 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { // Update list size m.list.SetSize(menuWidth-2, contentHeight) + // Calculate log height (subtract 1 for "Logs" title) + sidebarHeight := contentHeight - 1 + if sidebarHeight < 1 { + sidebarHeight = 1 + } + if !m.ready { - // Initialize viewport + // Initialize viewports m.viewport = viewport.New(contentWidth, contentHeight) m.viewport.SetContent("") + m.rightSidebar = viewport.New(rightSidebarWidth-2, sidebarHeight) + m.rightSidebar.SetContent("") m.ready = true } else { m.viewport.Width = contentWidth m.viewport.Height = contentHeight + m.rightSidebar.Width = rightSidebarWidth - 2 + m.rightSidebar.Height = sidebarHeight } return m, nil + case LogMsg: + // Update log viewport with new log entry + m.updateRightSidebar() + return m, nil + case tea.KeyMsg: + // If we're in a sub-view, handle escape to go back + if m.inSubView { + switch msg.String() { + case "esc": + // Only go back if we're in the list view of the sub-model + if m.envModel != nil && m.envModel.currentView == ViewList { + m.inSubView = false + m.showRightSidebar = false + m.envModel = nil + m.selectedItem = "" + // Recalculate layout without sidebar + m.viewport.Width = m.width - menuWidth - 1 + // Restore default shortcuts + m.footerShortcuts = "[a] Actions [/] Search [?] Help [q] Quit" + return m, nil + } + case "q", "ctrl+c": + m.quitting = true + return m, tea.Quit + } + + // Forward to sub-view + if m.envModel != nil { + var envCmd tea.Cmd + m.envModel, envCmd = m.envModel.Update(msg) + if envCmd != nil { + cmds = append(cmds, envCmd) + } + // Update viewport with environment view + m.viewport.SetContent(m.envModel.View()) + // Update footer shortcuts from environment model + m.footerShortcuts = m.envModel.FooterShortcuts() + } + + return m, tea.Batch(cmds...) + } + + // Main menu navigation switch msg.String() { case "q", "ctrl+c": m.quitting = true @@ -97,19 +180,53 @@ func (m *AppModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { case "enter": if item, ok := m.list.SelectedItem().(MenuItem); ok { m.selectedItem = item.Title() - m.viewport.SetContent(item.Title()) + // Handle different menu items + switch item.Title() { + case "Environment": + m.inSubView = true + m.showRightSidebar = true + m.logBuffer.Clear() // Clear previous logs + m.envModel = NewEnvironmentModel(m.tuiLogger, m.styles) + m.envModel.width = m.viewport.Width + m.envModel.height = m.viewport.Height + var envCmd tea.Cmd + envCmd = m.envModel.Init() + if envCmd != nil { + cmds = append(cmds, envCmd) + } + m.viewport.SetContent(m.envModel.View()) + // Set footer shortcuts from environment model + m.footerShortcuts = m.envModel.FooterShortcuts() + // Recalculate layout with sidebar visible + m.viewport.Width = m.width - menuWidth - rightSidebarWidth + m.rightSidebar.Width = rightSidebarWidth - 2 + default: + m.viewport.SetContent(item.Title()) + } } } } - // Update list - var cmd tea.Cmd - m.list, cmd = m.list.Update(msg) - cmds = append(cmds, cmd) - - // Update viewport - m.viewport, cmd = m.viewport.Update(msg) - cmds = append(cmds, cmd) + // Update sub-models if active + if m.inSubView && m.envModel != nil { + var envCmd tea.Cmd + m.envModel, envCmd = m.envModel.Update(msg) + if envCmd != nil { + cmds = append(cmds, envCmd) + } + m.viewport.SetContent(m.envModel.View()) + // Keep footer shortcuts in sync + m.footerShortcuts = m.envModel.FooterShortcuts() + } else { + // Update list + var cmd tea.Cmd + m.list, cmd = m.list.Update(msg) + cmds = append(cmds, cmd) + + // Update viewport + m.viewport, cmd = m.viewport.Update(msg) + cmds = append(cmds, cmd) + } return m, tea.Batch(cmds...) } @@ -130,7 +247,7 @@ func (m *AppModel) View() string { doc.WriteString(m.headerView()) doc.WriteString("\n") - // Main content area (menu + content) + // Main content area (menu + content + optional logs) { borderStyle := lipgloss.NewStyle().Foreground(m.styles.Theme.Border) @@ -139,25 +256,42 @@ func (m *AppModel) View() string { Width(menuWidth - 2). Height(m.viewport.Height) - // Content area style + // Content area style with left padding contentStyle := lipgloss.NewStyle(). Width(m.viewport.Width). - Height(m.viewport.Height) + Height(m.viewport.Height). + PaddingLeft(1) + + // Log area style with left padding + sidebarStyle := lipgloss.NewStyle(). + Width(rightSidebarWidth - 2). + Height(m.viewport.Height). + PaddingLeft(1) // Render menu and content menuRendered := menuStyle.Render(m.list.View()) contentRendered := contentStyle.Render(m.viewport.View()) + // Render logs if enabled + var sidebarRendered string + if m.showRightSidebar { + m.updateRightSidebar() + sidebarTitle := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Logs") + sidebarContent := m.rightSidebar.View() + sidebarRendered = sidebarStyle.Render(sidebarTitle + "\n" + sidebarContent) + } + // Split into lines menuLines := strings.Split(menuRendered, "\n") contentLines := strings.Split(contentRendered, "\n") - - // Ensure same number of lines - maxLines := len(menuLines) - if len(contentLines) > maxLines { - maxLines = len(contentLines) + var sidebarLines []string + if m.showRightSidebar { + sidebarLines = strings.Split(sidebarRendered, "\n") } + // Use viewport height as maxLines to prevent overflow + maxLines := m.viewport.Height + // Build bordered lines with proper alignment var borderedLines []string for i := 0; i < maxLines; i++ { @@ -171,7 +305,20 @@ func (m *AppModel) View() string { } // Pad menu line to exact width menuLine = padRight(menuLine, menuWidth-2) - line := borderStyle.Render("│") + menuLine + borderStyle.Render("│") + contentLine + borderStyle.Render("│") + + line := borderStyle.Render("│") + menuLine + borderStyle.Render("│") + contentLine + + // Add log section if enabled + if m.showRightSidebar { + sidebarLine := "" + if i < len(sidebarLines) { + sidebarLine = sidebarLines[i] + } + sidebarLine = padRight(sidebarLine, rightSidebarWidth-2) + line = line + borderStyle.Render("│") + sidebarLine + } + + line = line + borderStyle.Render("│") borderedLines = append(borderedLines, line) } doc.WriteString(strings.Join(borderedLines, "\n")) @@ -205,12 +352,63 @@ func (m *AppModel) headerView() string { innerWidth := m.width - 2 leftPart := strings.Repeat("─", menuWidth-2) junction := "┬" - rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) - bottomLine := borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + + var bottomLine string + if m.showRightSidebar { + // Calculate middle part width (content area) + middleWidth := innerWidth - menuWidth - rightSidebarWidth + 2 + if middleWidth < 0 { + middleWidth = 0 + } + middlePart := strings.Repeat("─", middleWidth) + rightPart := strings.Repeat("─", rightSidebarWidth-2) + bottomLine = borderStyle.Render("├" + leftPart + junction + middlePart + junction + rightPart + "┤") + } else { + rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) + bottomLine = borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + } return lipgloss.JoinVertical(lipgloss.Left, topLine, titleLine, bottomLine) } +// updateRightSidebar updates the log viewport with current log entries +func (m *AppModel) updateRightSidebar() { + if m.logBuffer == nil { + return + } + + entries := m.logBuffer.GetEntries() + var sidebarContent strings.Builder + + // Render log entries + for _, entry := range entries { + var levelStyle lipgloss.Style + switch entry.Level.String() { + case "ERROR", "error": + levelStyle = m.styles.ErrorStyle + case "WARN", "warn": + levelStyle = m.styles.WarningStyle + case "INFO", "info": + levelStyle = m.styles.SuccessStyle + default: + levelStyle = m.styles.MutedTextStyle + } + + sidebarLine := lipgloss.JoinHorizontal( + lipgloss.Left, + m.styles.MutedTextStyle.Render("["+entry.Time+"] "), + levelStyle.Render(entry.Level.CapitalString()+" "), + m.styles.TextStyle.Render(entry.Message), + ) + sidebarContent.WriteString(sidebarLine) + sidebarContent.WriteString("\n") + } + + m.rightSidebar.SetContent(sidebarContent.String()) + // Auto-scroll to bottom + m.rightSidebar.GotoBottom() +} + // footerView renders the footer with shortcuts and status func (m *AppModel) footerView() string { w := lipgloss.Width @@ -225,26 +423,28 @@ func (m *AppModel) footerView() string { Foreground(lipgloss.Color("#FFFDF5")). Padding(0, 1) - // Shortcuts style (left side with pink background) - shortcutsStyle := statusNugget. - Background(lipgloss.Color("#FF5F87")). + // Shortcuts style (left side with purple background) + shortcutsStyle := statusNugget.Copy(). + Background(lipgloss.Color("#A550DF")). MarginRight(1) // Namespace style (purple background) - namespaceStyle := statusNugget. - Background(lipgloss.Color("#A550DF")) + namespaceStyle := statusNugget.Copy(). + Background(lipgloss.Color("#6124DF")). + MarginLeft(1). + MarginRight(1) - // Release style (deep purple background) - releaseStyle := statusNugget. - Background(lipgloss.Color("#6124DF")) + // Release style (purple background) + releaseStyle := statusNugget.Copy(). + Background(lipgloss.Color("#6124DF")). + MarginRight(1) - // Version style - versionStyle := statusNugget. - Background(lipgloss.Color("#874BFD")) + // Version style (purple background) + versionStyle := statusNugget.Copy(). + Background(lipgloss.Color("#6124DF")) - // Build shortcuts - shortcuts := "[a] Actions [/] Search [?] Help [q] Quit" - shortcutsContent := shortcutsStyle.Render(shortcuts) + // Build shortcuts from dynamic field + shortcutsContent := shortcutsStyle.Render(m.footerShortcuts) // Status info versionNum := version.Version @@ -261,8 +461,8 @@ func (m *AppModel) footerView() string { Inherit(statusBarStyle). Padding(0, 1) - // Calculate remaining width for middle section - middleWidth := m.width - w(shortcutsContent) - w(namespaceContent) - w(releaseContent) - w(versionContent) + // Calculate remaining width for middle section (subtract 2 for outer borders) + middleWidth := m.width - 2 - w(shortcutsContent) - w(namespaceContent) - w(releaseContent) - w(versionContent) if middleWidth < 0 { middleWidth = 0 } @@ -284,8 +484,21 @@ func (m *AppModel) footerView() string { innerWidth := m.width - 2 leftPart := strings.Repeat("─", menuWidth-2) junction := "┴" - rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) - topLine := borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + + var topLine string + if m.showRightSidebar { + // Calculate middle part width (content area) + middleWidth := innerWidth - menuWidth - rightSidebarWidth + 2 + if middleWidth < 0 { + middleWidth = 0 + } + middlePart := strings.Repeat("─", middleWidth) + rightPart := strings.Repeat("─", rightSidebarWidth-2) + topLine = borderStyle.Render("├" + leftPart + junction + middlePart + junction + rightPart + "┤") + } else { + rightPart := strings.Repeat("─", max(0, innerWidth-menuWidth+1)) + topLine = borderStyle.Render("├" + leftPart + junction + rightPart + "┤") + } // Status bar with side borders - ensure single line with exact width statusBarContent := statusBarStyle.Width(m.width - 2).MaxHeight(1).Render(bar) @@ -307,6 +520,11 @@ func (m *AppModel) SetViewportContent(content string) { m.viewport.SetContent(content) } +// SetProgram sets the Bubble Tea program reference for sending log messages +func (m *AppModel) SetProgram(p *tea.Program) { + m.logBuffer.SetProgram(p) +} + func max(a, b int) int { if a > b { return a diff --git a/internal/tui/environment.go b/internal/tui/environment.go new file mode 100644 index 0000000..8d0a2af --- /dev/null +++ b/internal/tui/environment.go @@ -0,0 +1,684 @@ +package tui + +import ( + "context" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/charmbracelet/bubbles/list" + "github.com/charmbracelet/bubbles/table" + "github.com/charmbracelet/bubbles/textinput" + tea "github.com/charmbracelet/bubbletea" + "github.com/charmbracelet/lipgloss" + "github.com/web-seven/overlock/internal/engine" + "github.com/web-seven/overlock/pkg/environment" + "go.uber.org/zap" + "k8s.io/client-go/tools/clientcmd" + "sigs.k8s.io/controller-runtime/pkg/client/config" +) + +// EnvironmentView represents the current view state +type EnvironmentView int + +const ( + ViewList EnvironmentView = iota + ViewCreate + ViewDelete + ViewDetails +) + +// EnvironmentItem represents an environment in the list +type EnvironmentItem struct { + name string + engineType string + status string + description string +} + +// Implement list.Item interface +func (i EnvironmentItem) Title() string { return i.name } +func (i EnvironmentItem) Description() string { return fmt.Sprintf("%s • %s", i.engineType, i.status) } +func (i EnvironmentItem) FilterValue() string { return i.name } + +// EnvironmentModel manages the environment interface state +type EnvironmentModel struct { + logger *zap.SugaredLogger + styles Styles + width int + height int + currentView EnvironmentView + list list.Model + table table.Model + environments []EnvironmentItem + selectedEnv *EnvironmentItem + createInputs []textinput.Model + focusIndex int + confirmDelete bool + err error + loading bool + message string +} + +// NewEnvironmentModel creates a new environment management model +func NewEnvironmentModel(logger *zap.SugaredLogger, styles Styles) *EnvironmentModel { + // Create list for environment selection + items := []list.Item{} + delegate := list.NewDefaultDelegate() + l := list.New(items, delegate, 0, 0) + l.Title = "Environments" + l.SetShowStatusBar(false) + l.SetFilteringEnabled(true) + l.SetShowHelp(true) + + // Create table for environment list view + columns := []table.Column{ + {Title: "Name", Width: 30}, + {Title: "Type", Width: 10}, + {Title: "Status", Width: 15}, + } + t := table.New( + table.WithColumns(columns), + table.WithFocused(true), + table.WithHeight(10), + ) + + s := table.DefaultStyles() + s.Header = s.Header. + BorderStyle(lipgloss.NormalBorder()). + BorderForeground(styles.Theme.Border). + BorderBottom(true). + Bold(true). + Foreground(styles.Theme.Primary) + s.Selected = s.Selected. + Foreground(styles.Theme.SelectedFg). + Background(styles.Theme.SelectedBg). + Bold(true) + t.SetStyles(s) + + // Create text inputs for create form + inputs := make([]textinput.Model, 4) + + inputs[0] = textinput.New() + inputs[0].Placeholder = "my-environment" + inputs[0].Focus() + inputs[0].CharLimit = 50 + inputs[0].Width = 40 + inputs[0].Prompt = "> " + + inputs[1] = textinput.New() + inputs[1].Placeholder = "kind" + inputs[1].CharLimit = 10 + inputs[1].Width = 40 + inputs[1].Prompt = "> " + + inputs[2] = textinput.New() + inputs[2].Placeholder = "80" + inputs[2].CharLimit = 5 + inputs[2].Width = 40 + inputs[2].Prompt = "> " + + inputs[3] = textinput.New() + inputs[3].Placeholder = "443" + inputs[3].CharLimit = 5 + inputs[3].Width = 40 + inputs[3].Prompt = "> " + + return &EnvironmentModel{ + logger: logger, + styles: styles, + currentView: ViewList, + list: l, + table: t, + environments: []EnvironmentItem{}, + createInputs: inputs, + focusIndex: 0, + } +} + +// Init initializes the environment model +func (m *EnvironmentModel) Init() tea.Cmd { + return m.loadEnvironments +} + +// Update handles messages +func (m *EnvironmentModel) Update(msg tea.Msg) (*EnvironmentModel, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg := msg.(type) { + case tea.WindowSizeMsg: + m.width = msg.Width + m.height = msg.Height + m.table.SetWidth(msg.Width - 4) + m.table.SetHeight(msg.Height - 10) + m.list.SetSize(msg.Width-4, msg.Height-10) + + case tea.KeyMsg: + switch m.currentView { + case ViewList: + return m.handleListKeys(msg) + case ViewCreate: + return m.handleCreateKeys(msg) + case ViewDelete: + return m.handleDeleteKeys(msg) + case ViewDetails: + return m.handleDetailsKeys(msg) + } + + case environmentsLoadedMsg: + m.environments = msg.environments + m.loading = false + m.updateTableRows() + m.updateListItems() + + case environmentCreatedMsg: + m.loading = false + m.message = fmt.Sprintf("Environment '%s' created successfully!", msg.name) + m.currentView = ViewList + return m, m.loadEnvironments + + case environmentDeletedMsg: + m.loading = false + m.message = fmt.Sprintf("Environment '%s' deleted successfully!", msg.name) + m.currentView = ViewList + return m, m.loadEnvironments + + case errorMsg: + m.loading = false + m.err = msg.err + m.message = "" + + case clearMessageMsg: + m.message = "" + m.err = nil + } + + // Update sub-components + switch m.currentView { + case ViewList: + m.table, cmd = m.table.Update(msg) + cmds = append(cmds, cmd) + case ViewCreate: + for i := range m.createInputs { + m.createInputs[i], cmd = m.createInputs[i].Update(msg) + cmds = append(cmds, cmd) + } + } + + return m, tea.Batch(cmds...) +} + +// View renders the environment interface +func (m *EnvironmentModel) View() string { + if m.loading { + return m.styles.TextStyle.Render("Loading...") + } + + var content string + switch m.currentView { + case ViewList: + content = m.viewList() + case ViewCreate: + content = m.viewCreate() + case ViewDelete: + content = m.viewDelete() + case ViewDetails: + content = m.viewDetails() + } + + return content +} + +// FooterShortcuts returns the shortcuts for the footer based on current view +func (m *EnvironmentModel) FooterShortcuts() string { + switch m.currentView { + case ViewList: + return "[c] Create [d] Delete [enter] Details [r] Refresh [esc] Back [q] Quit" + case ViewCreate: + return "[tab] Navigate [enter] Submit [esc] Cancel [q] Quit" + case ViewDelete: + return "[y] Confirm [n] Cancel [esc] Back [q] Quit" + case ViewDetails: + return "[esc] Back [q] Quit" + default: + return "[esc] Back [q] Quit" + } +} + +// viewList renders the environment list view +func (m *EnvironmentModel) viewList() string { + var b strings.Builder + + // Title + title := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Environment Management") + b.WriteString(title) + b.WriteString("\n\n") + + // Show message or error + if m.message != "" { + msg := m.styles.SuccessStyle.Render("✓ " + m.message) + b.WriteString(msg) + b.WriteString("\n\n") + } + if m.err != nil { + errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) + b.WriteString(errMsg) + b.WriteString("\n\n") + } + + // Environment table + if len(m.environments) == 0 { + noEnvMsg := m.styles.MutedTextStyle.Render("No environments found.") + b.WriteString(noEnvMsg) + } else { + b.WriteString(m.table.View()) + } + + return b.String() +} + +// viewCreate renders the create environment form +func (m *EnvironmentModel) viewCreate() string { + var b strings.Builder + + // Title + title := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Create New Environment") + b.WriteString(title) + b.WriteString("\n\n") + + // Form fields + fields := []string{"Name", "Engine (kind/k3s/k3d)", "HTTP Port", "HTTPS Port"} + for i, field := range fields { + b.WriteString(m.styles.TextStyle.Render(field + ":")) + b.WriteString("\n") + + if i == m.focusIndex { + b.WriteString(m.createInputs[i].View()) + } else { + b.WriteString(m.styles.MutedTextStyle.Render(m.createInputs[i].View())) + } + } + + // Show error if any + if m.err != nil { + errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) + b.WriteString(errMsg) + } + + return b.String() +} + +// viewDelete renders the delete confirmation dialog +func (m *EnvironmentModel) viewDelete() string { + var b strings.Builder + + // Title + title := m.styles.ErrorStyle.Render("⚠ Delete Environment") + b.WriteString(title) + b.WriteString("\n\n") + + if m.selectedEnv == nil { + b.WriteString(m.styles.TextStyle.Render("No environment selected.")) + return b.String() + } + + // Confirmation message + msg := fmt.Sprintf("Are you sure you want to delete environment '%s'?", m.selectedEnv.name) + b.WriteString(m.styles.TextStyle.Render(msg)) + b.WriteString("\n\n") + + // Environment details + details := fmt.Sprintf("Type: %s\nStatus: %s", m.selectedEnv.engineType, m.selectedEnv.status) + b.WriteString(m.styles.MutedTextStyle.Render(details)) + b.WriteString("\n\n") + + // Warning + warning := m.styles.WarningStyle.Render("⚠ This action cannot be undone!") + b.WriteString(warning) + b.WriteString("\n\n") + + // Show error if any + if m.err != nil { + errMsg := m.styles.ErrorStyle.Render("✗ Error: " + m.err.Error()) + b.WriteString(errMsg) + } + + return b.String() +} + +// viewDetails renders the environment details view +func (m *EnvironmentModel) viewDetails() string { + var b strings.Builder + + // Title + title := m.styles.TextStyle.Bold(true).Foreground(m.styles.Theme.Primary).Render("Environment Details") + b.WriteString(title) + b.WriteString("\n\n") + + if m.selectedEnv == nil { + b.WriteString(m.styles.TextStyle.Render("No environment selected.")) + return b.String() + } + + // Environment details + nameLabel := m.styles.KeyStyle.Render("Name:") + nameValue := m.styles.ValueStyle.Render(m.selectedEnv.name) + b.WriteString(fmt.Sprintf("%s %s\n", nameLabel, nameValue)) + + typeLabel := m.styles.KeyStyle.Render("Type:") + typeValue := m.styles.ValueStyle.Render(m.selectedEnv.engineType) + b.WriteString(fmt.Sprintf("%s %s\n", typeLabel, typeValue)) + + statusLabel := m.styles.KeyStyle.Render("Status:") + statusStyle := m.styles.SuccessStyle + if m.selectedEnv.status == "inactive" { + statusStyle = m.styles.MutedTextStyle + } + statusValue := statusStyle.Render(m.selectedEnv.status) + b.WriteString(fmt.Sprintf("%s %s\n", statusLabel, statusValue)) + + return b.String() +} + +// Key handlers + +func (m *EnvironmentModel) handleListKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + var cmd tea.Cmd + + switch msg.String() { + case "c": + // Create new environment + m.currentView = ViewCreate + m.err = nil + m.message = "" + m.focusIndex = 0 + m.createInputs[0].Focus() + for i := 1; i < len(m.createInputs); i++ { + m.createInputs[i].Blur() + } + return m, nil + + case "d": + // Delete environment + if len(m.environments) > 0 { + selectedIdx := m.table.Cursor() + if selectedIdx < len(m.environments) { + m.selectedEnv = &m.environments[selectedIdx] + m.currentView = ViewDelete + m.err = nil + m.message = "" + } + } + return m, nil + + case "enter": + // View details + if len(m.environments) > 0 { + selectedIdx := m.table.Cursor() + if selectedIdx < len(m.environments) { + m.selectedEnv = &m.environments[selectedIdx] + m.currentView = ViewDetails + m.err = nil + m.message = "" + } + } + return m, nil + + case "r": + // Refresh list + m.loading = true + m.err = nil + m.message = "" + return m, m.loadEnvironments + + default: + // Clear messages on any other key + if m.message != "" || m.err != nil { + m.message = "" + m.err = nil + } + m.table, cmd = m.table.Update(msg) + return m, cmd + } +} + +func (m *EnvironmentModel) handleCreateKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + var cmd tea.Cmd + var cmds []tea.Cmd + + switch msg.String() { + case "esc": + // Cancel creation + m.currentView = ViewList + m.err = nil + // Reset form + for i := range m.createInputs { + m.createInputs[i].SetValue("") + } + return m, nil + + case "tab", "shift+tab", "up", "down": + // Navigate between inputs + if msg.String() == "up" || msg.String() == "shift+tab" { + m.focusIndex-- + } else { + m.focusIndex++ + } + + if m.focusIndex < 0 { + m.focusIndex = len(m.createInputs) - 1 + } else if m.focusIndex >= len(m.createInputs) { + m.focusIndex = 0 + } + + for i := range m.createInputs { + if i == m.focusIndex { + m.createInputs[i].Focus() + } else { + m.createInputs[i].Blur() + } + } + return m, nil + + case "enter": + // Submit form + name := m.createInputs[0].Value() + engine := m.createInputs[1].Value() + httpPort := m.createInputs[2].Value() + httpsPort := m.createInputs[3].Value() + + // Validate + if name == "" { + m.err = fmt.Errorf("environment name is required") + return m, nil + } + if engine == "" { + engine = "kind" + } + if httpPort == "" { + httpPort = "80" + } + if httpsPort == "" { + httpsPort = "443" + } + + m.loading = true + m.err = nil + return m, m.createEnvironment(name, engine, httpPort, httpsPort) + } + + // For all other keys (regular text input), update the focused input + for i := range m.createInputs { + m.createInputs[i], cmd = m.createInputs[i].Update(msg) + cmds = append(cmds, cmd) + } + + return m, tea.Batch(cmds...) +} + +func (m *EnvironmentModel) handleDeleteKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + switch msg.String() { + case "y": + // Confirm delete + if m.selectedEnv != nil { + m.loading = true + m.err = nil + return m, m.deleteEnvironment(m.selectedEnv.name, m.selectedEnv.engineType) + } + return m, nil + + case "n", "esc": + // Cancel delete + m.currentView = ViewList + m.selectedEnv = nil + m.err = nil + return m, nil + } + + return m, nil +} + +func (m *EnvironmentModel) handleDetailsKeys(msg tea.KeyMsg) (*EnvironmentModel, tea.Cmd) { + switch msg.String() { + case "esc": + // Go back to list + m.currentView = ViewList + m.selectedEnv = nil + m.err = nil + return m, nil + } + + return m, nil +} + +// Data loading and operations + +func (m *EnvironmentModel) loadEnvironments() tea.Msg { + environments := []EnvironmentItem{} + + kubeconfig := os.Getenv("KUBECONFIG") + if kubeconfig == "" { + kubeconfig = filepath.Join(os.Getenv("HOME"), ".kube", "config") + } + + configFile, err := clientcmd.LoadFromFile(kubeconfig) + if err != nil { + return errorMsg{err: err} + } + + for name := range configFile.Contexts { + configClient, err := config.GetConfigWithContext(name) + if err != nil { + continue + } + + if engine.IsHelmReleaseFound(configClient) { + types := regexp.MustCompile(`(\w+)`).FindStringSubmatch(name) + engineType := "unknown" + if len(types) > 0 { + engineType = strings.ToLower(types[0]) + } + + status := "active" + // TODO: Implement actual status checking + // For now, we'll assume all are active + + environments = append(environments, EnvironmentItem{ + name: name, + engineType: engineType, + status: status, + }) + } + } + + return environmentsLoadedMsg{environments: environments} +} + +func (m *EnvironmentModel) createEnvironment(name, engine, httpPort, httpsPort string) tea.Cmd { + return func() tea.Msg { + ctx := context.Background() + + // Parse ports + var http, https int + fmt.Sscanf(httpPort, "%d", &http) + fmt.Sscanf(httpsPort, "%d", &https) + + if http == 0 { + http = 80 + } + if https == 0 { + https = 443 + } + + // Create environment using the existing package + err := environment. + New(engine, name). + WithHttpPort(http). + WithHttpsPort(https). + Create(ctx, m.logger) + + if err != nil { + return errorMsg{err: err} + } + + return environmentCreatedMsg{name: name} + } +} + +func (m *EnvironmentModel) deleteEnvironment(name, engine string) tea.Cmd { + return func() tea.Msg { + // Delete environment using the existing package + err := environment. + New(engine, name). + Delete(true, m.logger) // true = skip confirmation (we already confirmed in TUI) + + if err != nil { + return errorMsg{err: err} + } + + return environmentDeletedMsg{name: name} + } +} + +func (m *EnvironmentModel) updateTableRows() { + rows := []table.Row{} + for _, env := range m.environments { + rows = append(rows, table.Row{ + env.name, + strings.ToUpper(env.engineType), + env.status, + }) + } + m.table.SetRows(rows) +} + +func (m *EnvironmentModel) updateListItems() { + items := []list.Item{} + for _, env := range m.environments { + items = append(items, env) + } + m.list.SetItems(items) +} + +// Messages + +type environmentsLoadedMsg struct { + environments []EnvironmentItem +} + +type environmentCreatedMsg struct { + name string +} + +type environmentDeletedMsg struct { + name string +} + +type errorMsg struct { + err error +} + +type clearMessageMsg struct{} diff --git a/internal/tui/logger.go b/internal/tui/logger.go new file mode 100644 index 0000000..8081609 --- /dev/null +++ b/internal/tui/logger.go @@ -0,0 +1,132 @@ +package tui + +import ( + "sync" + + tea "github.com/charmbracelet/bubbletea" + "go.uber.org/zap" + "go.uber.org/zap/zapcore" +) + +// LogEntry represents a single log entry +type LogEntry struct { + Level zapcore.Level + Message string + Time string +} + +// LogSink is a custom zap core that captures logs for TUI display +type LogSink struct { + zapcore.LevelEnabler + encoder zapcore.Encoder + output *LogBuffer +} + +// LogBuffer stores log entries and sends them to the TUI +type LogBuffer struct { + mu sync.RWMutex + entries []LogEntry + program *tea.Program +} + +// NewLogBuffer creates a new log buffer +func NewLogBuffer() *LogBuffer { + return &LogBuffer{ + entries: make([]LogEntry, 0), + } +} + +// SetProgram sets the Bubble Tea program for sending messages +func (b *LogBuffer) SetProgram(p *tea.Program) { + b.mu.Lock() + defer b.mu.Unlock() + b.program = p +} + +// Add appends a log entry and sends it to the TUI +func (b *LogBuffer) Add(entry LogEntry) { + b.mu.Lock() + defer b.mu.Unlock() + b.entries = append(b.entries, entry) + + // Send message to TUI if program is set + if b.program != nil { + b.program.Send(LogMsg{Entry: entry}) + } +} + +// GetEntries returns all log entries +func (b *LogBuffer) GetEntries() []LogEntry { + b.mu.RLock() + defer b.mu.RUnlock() + + // Return a copy to avoid race conditions + entries := make([]LogEntry, len(b.entries)) + copy(entries, b.entries) + return entries +} + +// Clear removes all log entries +func (b *LogBuffer) Clear() { + b.mu.Lock() + defer b.mu.Unlock() + b.entries = make([]LogEntry, 0) +} + +// LogMsg is a Bubble Tea message for log updates +type LogMsg struct { + Entry LogEntry +} + +// NewLogSink creates a new log sink +func NewLogSink(level zapcore.Level, output *LogBuffer) *LogSink { + encoderConfig := zap.NewDevelopmentEncoderConfig() + encoderConfig.EncodeTime = zapcore.TimeEncoderOfLayout("15:04:05") + encoderConfig.EncodeLevel = zapcore.CapitalColorLevelEncoder + + return &LogSink{ + LevelEnabler: level, + encoder: zapcore.NewConsoleEncoder(encoderConfig), + output: output, + } +} + +// With adds structured context to the logger +func (s *LogSink) With(fields []zapcore.Field) zapcore.Core { + return s +} + +// Check determines whether the supplied Entry should be logged +func (s *LogSink) Check(entry zapcore.Entry, checked *zapcore.CheckedEntry) *zapcore.CheckedEntry { + if s.LevelEnabler.Enabled(entry.Level) { + return checked.AddCore(entry, s) + } + return checked +} + +// Write serializes the Entry and any Fields supplied +func (s *LogSink) Write(entry zapcore.Entry, fields []zapcore.Field) error { + // Create log entry + logEntry := LogEntry{ + Level: entry.Level, + Message: entry.Message, + Time: entry.Time.Format("15:04:05"), + } + + // Add to buffer + s.output.Add(logEntry) + + return nil +} + +// Sync flushes buffered logs +func (s *LogSink) Sync() error { + return nil +} + +// CreateTUILogger creates a logger that outputs to the TUI log buffer +func CreateTUILogger(buffer *LogBuffer) *zap.SugaredLogger { + core := NewLogSink(zapcore.DebugLevel, buffer) + logger := zap.New(core) + return logger.Sugar() +} diff --git a/internal/tui/menu.go b/internal/tui/menu.go index bc9c0df..0a3ee6b 100644 --- a/internal/tui/menu.go +++ b/internal/tui/menu.go @@ -15,7 +15,7 @@ type MenuItem struct { func (i MenuItem) Title() string { return i.title } // Description returns the menu item description (implements list.Item) -func (i MenuItem) Description() string { return i.description } +func (i MenuItem) Description() string { return "" } // FilterValue returns the filter value (implements list.Item) func (i MenuItem) FilterValue() string { return i.title }