package main import ( "flag" "fmt" "os" "time" "envguard/internal/api" "envguard/internal/models" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) var baseStyle = lipgloss.NewStyle(). BorderStyle(lipgloss.NormalBorder()). BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")) type viewState int const ( viewList viewState = iota viewDetail viewLockForm ) type model struct { table table.Model services []models.Service user string err error message string width int height int state viewState history []models.HistoryEntry historyTable table.Model inputs []textinput.Model focusIndex int } type tickMsg time.Time func tick() tea.Cmd { return tea.Tick(5*time.Second, func(t time.Time) tea.Msg { return tickMsg(t) }) } func (m model) Init() tea.Cmd { // If in detail view, only tick (no auto refresh of history for now to keep it simple) if m.state == viewDetail { return tick() } return tea.Batch(fetchServices, tick()) } func (m model) Update(msg tea.Msg) (tea.Model, tea.Cmd) { var cmd tea.Cmd switch msg := msg.(type) { case tickMsg: if m.state == viewLockForm { return m, nil } return m, tea.Batch(fetchServices, tick()) case tea.KeyMsg: if m.state == viewLockForm { return m.updateLockForm(msg) } switch msg.String() { case "esc", "q", "ctrl+c": if m.state == viewDetail { m.state = viewList m.table.Focus() return m, nil } return m, tea.Quit case "d": if m.state == viewList { selected := m.table.SelectedRow() if selected != nil { serviceName := selected[0] m.state = viewDetail return m, fetchHistoryCmd(serviceName) } } case "enter": if m.state == viewDetail { return m, nil } selected := m.table.SelectedRow() if selected == nil { return m, nil } serviceName := selected[0] // Find service state for _, s := range m.services { if s.Name == serviceName { if s.IsLocked { if s.LockedBy == m.user { // Unlock err := api.UnlockService(serviceName, m.user) if err != nil { m.message = "Error unlocking: " + err.Error() } else { m.message = "Unlocked " + serviceName } return m, fetchServices } else { m.message = "Cannot unlock service locked by " + s.LockedBy } } else { // Open Lock Form m.state = viewLockForm m.inputs[0].SetValue("") m.inputs[1].SetValue("") m.focusIndex = 0 m.inputs[0].Focus() return m, nil } return m, fetchServices } } case "r": return m, fetchServices } case tea.WindowSizeMsg: m.width = msg.Width m.height = msg.Height // Calculate available dimensions // Borders take 2 chars total width (1 left + 1 right) // We add extra safety margin (total -6) to avoid last-column wrap issues availWidth := m.width - 15 // Adjust column widths dynamically // Total weight = 100% cols := m.table.Columns() // Calculate widths ensuring at least minimums // Columns: SERVICE, STATUS, USER, TICKETS, TIME // Main View c0 := int(float64(availWidth) * 0.25) // SERVICE c1 := int(float64(availWidth) * 0.15) // STATUS c2 := int(float64(availWidth) * 0.15) // USER c3 := int(float64(availWidth) * 0.20) // TICKETS c4 := availWidth - c0 - c1 - c2 - c3 // TIME cols[0].Width = c0 cols[1].Width = c1 cols[2].Width = c2 cols[3].Width = c3 cols[4].Width = c4 m.table.SetColumns(cols) m.table.SetWidth(availWidth) // History View (Split 50/50 approx) // Left Pane (Table) // Columns: ACTION, USER, TIME (Tickets and Desc moved to right pane) // but wait, we need to match the history table initialization cols... // Let's redefine history table cols to match the new layout hWidth := availWidth / 2 hc0 := int(float64(hWidth) * 0.2) // ACTION hc1 := int(float64(hWidth) * 0.3) // USER hc2 := hWidth - hc0 - hc1 - 5 // TIME (approx) hCols := m.historyTable.Columns() // Ensure we have correct number of columns. // Previous task added TICKETS and DESC columns. We will remove them from the table // and show them in the right pane instead, to save space. // Wait, I should not change the columns definition in main() if I don't want to re-init. // Re-init in main() is better. I will do that in a separate chunk. // For now, let's assume I will reduce the history table columns to 3. // Actually, I can just hide or set width 0 for columns I don't want to see? // No, better to stick to 3 visible columns for the table. if len(hCols) >= 3 { hCols[0].Width = hc0 hCols[1].Width = hc1 hCols[len(hCols)-1].Width = hc2 // Set middle columns to 0 if they exist (TICKETS, DESC) for i := 2; i < len(hCols)-1; i++ { hCols[i].Width = 0 } } m.historyTable.SetColumns(hCols) m.historyTable.SetWidth(hWidth) // Header + Footer + padding calculation availableHeight := m.height - 7 if availableHeight < 5 { availableHeight = 5 } m.table.SetHeight(availableHeight) case []models.Service: m.services = msg rows := []table.Row{} for _, s := range msg { status := "🟢 LIBRE" if s.IsLocked { status = "🔴 LOCKED" } user := s.LockedBy if user == "" { user = "--" } timeStr := "--" if s.IsLocked { duration := time.Since(s.LockedAt) timeStr = fmt.Sprintf("Hace %s", duration.Round(time.Second)) } rows = append(rows, table.Row{s.Name, status, user, s.JiraTickets, timeStr}) } m.table.SetRows(rows) m.message = "Refreshed" case []models.HistoryEntry: m.history = msg rows := []table.Row{} for _, h := range msg { rows = append(rows, table.Row{ h.Action, h.User, h.JiraTickets, h.Description, h.Timestamp.Format("2006-01-02 15:04:05"), }) } m.historyTable.SetRows(rows) m.historyTable.SetRows(rows) // Resize handled in WindowSizeMsg now, but we need to trigger it if not triggered. // Actually m.width is stored. // Recalculate widths just in case availWidth := m.width - 10 if availWidth > 0 { hWidth := availWidth / 2 hc0 := int(float64(hWidth) * 0.2) // ACTION hc1 := int(float64(hWidth) * 0.3) // USER hc2 := hWidth - hc0 - hc1 - 5 // TIME hCols := m.historyTable.Columns() if len(hCols) >= 3 { hCols[0].Width = hc0 hCols[1].Width = hc1 hCols[len(hCols)-1].Width = hc2 for i := 2; i < len(hCols)-1; i++ { hCols[i].Width = 0 } } m.historyTable.SetColumns(hCols) m.historyTable.SetWidth(hWidth) } m.historyTable.SetHeight(m.table.Height()) case error: m.err = msg } if m.state == viewList { m.table, cmd = m.table.Update(msg) } else { m.historyTable, cmd = m.historyTable.Update(msg) } return m, cmd } func (m model) updateLockForm(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "esc": m.state = viewList m.table.Focus() return m, nil case "tab", "shift+tab", "enter", "up", "down": s := msg.String() if s == "enter" && m.focusIndex == len(m.inputs)-1 { // Submit serviceName := m.table.SelectedRow()[0] jira := m.inputs[0].Value() desc := m.inputs[1].Value() err := api.LockService(serviceName, m.user, jira, desc) if err != nil { m.message = "Error locking: " + err.Error() } else { m.message = "Locked " + serviceName } m.state = viewList m.table.Focus() return m, fetchServices } if s == "up" || s == "shift+tab" { m.focusIndex-- } else { m.focusIndex++ } if m.focusIndex > len(m.inputs)-1 { m.focusIndex = 0 } else if m.focusIndex < 0 { m.focusIndex = len(m.inputs) - 1 } cmds := make([]tea.Cmd, len(m.inputs)) for i := 0; i <= len(m.inputs)-1; i++ { if i == m.focusIndex { cmds[i] = m.inputs[i].Focus() continue } m.inputs[i].Blur() } return m, tea.Batch(cmds...) } } cmd := m.updateInputs(msg) return m, cmd } func (m *model) updateInputs(msg tea.Msg) tea.Cmd { cmds := make([]tea.Cmd, len(m.inputs)) for i := range m.inputs { m.inputs[i], cmds[i] = m.inputs[i].Update(msg) } return tea.Batch(cmds...) } func (m model) View() string { if m.err != nil { return fmt.Sprintf("Error: %v\nPress q to quit", m.err) } if m.width == 0 { return "Loading..." } userInfo := fmt.Sprintf("User: %s", m.user) if m.message != "" { userInfo += " | " + m.message } // Dimensions appWidth := m.width - 2 appHeight := m.height - 2 if appWidth < 20 { appWidth = 20 } if appHeight < 10 { appHeight = 10 } // Content inside border contentWidth := appWidth - 2 // Local Styles headerStyle := lipgloss.NewStyle(). Bold(true). Foreground(lipgloss.Color("252")). PaddingLeft(1). PaddingBottom(1). Width(contentWidth) infoStyle := lipgloss.NewStyle(). PaddingLeft(1). Foreground(lipgloss.Color("241")). Width(contentWidth) footerStyle := lipgloss.NewStyle(). Foreground(lipgloss.Color("240")). Align(lipgloss.Center). Width(contentWidth) var mainView string if m.state == viewList { mainView = m.table.View() } else if m.state == viewLockForm { mainView = lipgloss.JoinVertical(lipgloss.Left, fmt.Sprintf("Locking service: %s", m.table.SelectedRow()[0]), "", "Jira Tickets:", m.inputs[0].View(), "", "Description:", m.inputs[1].View(), "", "(Enter to submit, Esc to cancel)", ) } else { // History View // Left Pane: History Table leftPane := m.historyTable.View() // Right Pane: Details var rightPane string selected := m.historyTable.SelectedRow() if selected != nil { // Extract data from selected row // Columns: ACTION, USER, TICKETS, DESC, TIME // Indices: 0, 1, 2, 3, 4 action := selected[0] user := selected[1] tickets := selected[2] desc := selected[3] timestamp := selected[4] detailStyle := lipgloss.NewStyle(). Padding(1). Border(lipgloss.RoundedBorder()). BorderForeground(lipgloss.Color("63")). Width(m.width/2 - 5). Height(m.height - 8) content := fmt.Sprintf( "ACTION: %s\n\nUSER: %s\n\nTIME: %s\n\nJIRA TICKETS:\n%s\n\nDESCRIPTION:\n%s", action, user, timestamp, tickets, desc, ) rightPane = detailStyle.Render(content) } else { rightPane = "Select an entry to view details." } mainView = lipgloss.JoinHorizontal(lipgloss.Top, leftPane, rightPane, ) } // Render Content content := lipgloss.JoinVertical(lipgloss.Left, headerStyle.Render("🛡️ ENV-GUARD v1.0"), infoStyle.Render(userInfo), mainView, footerStyle.Render("[↑/↓] Navigate [Enter] Toggle Lock [d] History [r] Refresh [q] Quit"), ) // Create Border Style locally borderStyle := lipgloss.NewStyle(). // BorderStyle(lipgloss.NormalBorder()). // BorderForeground(lipgloss.Color("240")). Width(appWidth). Height(appHeight - 2) // Wrap in Border gui := borderStyle.Render(content) // Place in center of screen return lipgloss.Place(m.width, m.height, lipgloss.Center, lipgloss.Center, gui) } func fetchServices() tea.Msg { services, err := api.GetServices() if err != nil { return err } return services } func fetchHistoryCmd(serviceName string) tea.Cmd { return func() tea.Msg { hist, err := api.GetHistory(serviceName) if err != nil { return err } return hist } } func main() { serverURL := flag.String("server", "http://localhost:8080", "URL of the EnvGuard server") flag.Parse() api.SetBaseURL(*serverURL) user := os.Getenv("USER") if user == "" { user = "guest" } columns := []table.Column{ {Title: "SERVICE", Width: 20}, {Title: "STATUS", Width: 15}, {Title: "USER", Width: 15}, {Title: "TICKETS", Width: 15}, {Title: "TIME", Width: 20}, } t := table.New( table.WithColumns(columns), table.WithFocused(true), table.WithHeight(10), ) s := table.DefaultStyles() s.Header = s.Header. BorderStyle(lipgloss.NormalBorder()). BorderForeground(lipgloss.Color("240")). BorderBottom(true). Bold(true) s.Selected = s.Selected. Foreground(lipgloss.Color("229")). Background(lipgloss.Color("57")). Bold(false) t.SetStyles(s) // History Table // Reduced columns for split view hCols := []table.Column{ {Title: "ACTION", Width: 10}, {Title: "USER", Width: 15}, // Hidden columns for data storage in row, but width 0 {Title: "TICKETS", Width: 0}, {Title: "DESC", Width: 0}, {Title: "TIME", Width: 20}, } ht := table.New( table.WithColumns(hCols), table.WithFocused(true), table.WithHeight(10), ) ht.SetStyles(s) inputs := make([]textinput.Model, 2) inputs[0] = textinput.New() inputs[0].Placeholder = "D3A-XXXX" inputs[0].Focus() inputs[0].CharLimit = 50 inputs[0].Width = 30 inputs[1] = textinput.New() inputs[1].Placeholder = "What are you doing?" inputs[1].CharLimit = 100 inputs[1].Width = 50 m := model{ table: t, historyTable: ht, user: user, inputs: inputs, } if _, err := tea.NewProgram(m, tea.WithAltScreen()).Run(); err != nil { fmt.Println("Error running program:", err) os.Exit(1) } }