package tui import ( "database/sql" "fmt" "os" "os/exec" "github.com/charmbracelet/bubbles/table" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type ReportsModel struct { db *sql.DB table table.Model rows []reportRow status string width int height int } type reportRow struct { ID int Seq int Company string Title string Score float64 Legitimacy string FilePath string CreatedAt string } func NewReportsModel(db *sql.DB) *ReportsModel { m := &ReportsModel{db: db, status: "[r] refresh [enter] open report"} m.load() m.table = newReportsTable(m.rows, 0, 0) return m } func (m *ReportsModel) load() { m.rows = nil if m.db == nil { m.status = "no database" return } rows, err := m.db.Query(` SELECT r.id, COALESCE(r.seq,0), COALESCE(j.company,''), COALESCE(j.title,''), COALESCE(r.score, 0), COALESCE(r.legitimacy, ''), COALESCE(r.file_path, ''), COALESCE(r.created_at, '') FROM reports r LEFT JOIN applications a ON a.id = r.application_id LEFT JOIN jobs j ON j.id = a.job_id ORDER BY r.id DESC LIMIT 200 `) if err != nil { m.status = fmt.Sprintf("query failed: %v", err) return } defer rows.Close() for rows.Next() { var r reportRow if err := rows.Scan(&r.ID, &r.Seq, &r.Company, &r.Title, &r.Score, &r.Legitimacy, &r.FilePath, &r.CreatedAt); err != nil { continue } m.rows = append(m.rows, r) } } func reportsColumns() []table.Column { return []table.Column{ {Title: "#", Width: 5}, {Title: "Company", Width: 22}, {Title: "Title", Width: 30}, {Title: "Score", Width: 6}, {Title: "Legit", Width: 10}, {Title: "Created", Width: 19}, } } func newReportsTable(rows []reportRow, w, h int) table.Model { cols := flexColumns(w, reportsColumns(), []int{1, 2}) trows := make([]table.Row, 0, len(rows)) for _, r := range rows { trows = append(trows, reportRowToTable(r)) } t := table.New( table.WithColumns(cols), table.WithRows(trows), table.WithFocused(true), table.WithHeight(tableHeight(h)), ) s := table.DefaultStyles() s.Header = s.Header.BorderStyle(lipgloss.NormalBorder()). BorderForeground(ColorBorder).BorderBottom(true).Bold(true).Foreground(ColorAccentAlt) s.Selected = s.Selected.Bold(true).Foreground(ColorText).Background(ColorHighlight) t.SetStyles(s) return t } func reportRowToTable(r reportRow) table.Row { seq := "-" if r.Seq > 0 { seq = fmt.Sprintf("#%03d", r.Seq) } score := "-" if r.Score > 0 { score = fmt.Sprintf("%.1f", r.Score) } return table.Row{seq, r.Company, r.Title, score, r.Legitimacy, r.CreatedAt} } func (m *ReportsModel) Init() tea.Cmd { return nil } func (m *ReportsModel) Resize(w, h int) { m.width = w m.height = h m.table.SetHeight(tableHeight(h)) m.table.SetWidth(w - 2) m.table.SetColumns(flexColumns(w-2, reportsColumns(), []int{1, 2})) } func (m *ReportsModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "r": m.load() m.refreshTable() m.status = fmt.Sprintf("%d reports", len(m.rows)) case "enter": if cur := m.table.Cursor(); cur < len(m.rows) { path := m.rows[cur].FilePath if path == "" { m.status = "no file_path on this report" return m, nil } if _, err := os.Stat(path); err != nil { m.status = fmt.Sprintf("missing: %s", path) return m, nil } m.openInPager(path) } } } var cmd tea.Cmd m.table, cmd = m.table.Update(msg) return m, cmd } func (m *ReportsModel) refreshTable() { trows := make([]table.Row, 0, len(m.rows)) for _, r := range m.rows { trows = append(trows, reportRowToTable(r)) } m.table.SetRows(trows) } func (m *ReportsModel) openInPager(path string) { pager := os.Getenv("PAGER") if pager == "" { pager = "less" } cmd := exec.Command(pager, path) cmd.Stdin = os.Stdin cmd.Stdout = os.Stdout cmd.Stderr = os.Stderr _ = cmd.Run() } func (m *ReportsModel) View() string { header := StyleTitle.Render("Reports — evaluation history") sub := StyleSubtitle.Render(fmt.Sprintf("%d reports · [r] refresh · [enter] open in $PAGER", len(m.rows))) status := StyleStatusMuted.Render(m.status) return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", m.table.View(), "", status) }