Files
apex-public/internal/tui/scan_views.go
T
2026-07-06 11:05:50 -04:00

326 lines
7.6 KiB
Go

package tui
import (
"context"
"database/sql"
"fmt"
"strings"
"time"
"github.com/cobr-ai/apex/internal/scan"
"github.com/charmbracelet/bubbles/table"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
type ScanModel struct {
configPath string
cfg *scan.Config
db *sql.DB
table table.Model
rows []scanRow
running bool
cancel context.CancelFunc
resultsCh <-chan scan.ScanResult
status string
width int
height int
showDetail bool
detailIdx int
}
type scanRow struct {
Name string
Status string
Found int
Filtered int
Duplicate int
New int
Err string
}
func NewScanModel(configPath string, db *sql.DB) *ScanModel {
m := &ScanModel{configPath: configPath, db: db, status: "press [s] to scan"}
cfg, err := scan.LoadConfig(configPath)
if err != nil {
m.status = fmt.Sprintf("config error: %v", err)
return m
}
m.cfg = cfg
for _, c := range cfg.TrackedCompanies {
st := "pending"
if !c.Enabled {
st = "disabled"
}
m.rows = append(m.rows, scanRow{Name: c.Name, Status: st})
}
m.table = newScanTable(m.rows, 0, 0)
return m
}
func scanColumns() []table.Column {
return []table.Column{
{Title: "Company", Width: 26},
{Title: "Status", Width: 11},
{Title: "Found", Width: 6},
{Title: "Filt", Width: 6},
{Title: "Dup", Width: 6},
{Title: "New", Width: 6},
{Title: "Notes", Width: 24},
}
}
func newScanTable(rows []scanRow, w, h int) table.Model {
cols := flexColumns(w, scanColumns(), []int{0, 6})
tableRows := make([]table.Row, 0, len(rows))
for _, r := range rows {
notes := r.Err
if notes == "" && r.Status == "unsupported" {
notes = "no tier-1 API"
}
tableRows = append(tableRows, table.Row{
r.Name, r.Status, intStr(r.Found), intStr(r.Filtered),
intStr(r.Duplicate), intStr(r.New), notes,
})
}
t := table.New(
table.WithColumns(cols),
table.WithRows(tableRows),
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 tableHeight(paneH int) int {
h := paneH - 6
if h < 5 {
h = 5
}
return h
}
func intStr(n int) string {
if n == 0 {
return "-"
}
return fmt.Sprintf("%d", n)
}
func (m *ScanModel) Init() tea.Cmd { return nil }
func (m *ScanModel) 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, scanColumns(), []int{0, 6}))
}
type scanResultMsg scan.ScanResult
type scanDoneMsg struct{}
func (m *ScanModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
if m.showDetail {
if k, ok := msg.(tea.KeyMsg); ok {
switch k.String() {
case "esc", "enter", "q":
m.showDetail = false
}
}
return m, nil
}
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "s":
if !m.running && m.cfg != nil {
return m, m.startScan()
}
case "esc":
if m.running && m.cancel != nil {
m.cancel()
}
case "r":
if !m.running {
return m, m.retryErrored()
}
case "enter":
m.detailIdx = m.table.Cursor()
m.showDetail = true
return m, nil
}
case scanResultMsg:
m.applyResult(scan.ScanResult(msg))
m.refreshTable()
return m, m.pump()
case scanDoneMsg:
m.running = false
m.status = m.summary()
m.refreshTable()
}
var cmd tea.Cmd
m.table, cmd = m.table.Update(msg)
return m, cmd
}
func (m *ScanModel) startScan() tea.Cmd {
if m.db == nil {
m.status = "scan unavailable: no database"
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Minute)
m.cancel = cancel
runner := scan.NewRunner(m.db, m.cfg)
m.resultsCh = runner.Run(ctx, m.cfg.TrackedCompanies)
m.running = true
m.status = "scanning…"
for i := range m.rows {
if m.rows[i].Status == "pending" || m.rows[i].Status == "error" {
m.rows[i].Status = "running"
m.rows[i].Err = ""
}
}
m.refreshTable()
return m.pump()
}
func (m *ScanModel) retryErrored() tea.Cmd {
if m.db == nil || m.cfg == nil {
return nil
}
var targets []scan.Company
for i, r := range m.rows {
if r.Status == "error" {
for _, c := range m.cfg.TrackedCompanies {
if c.Name == r.Name {
targets = append(targets, c)
m.rows[i].Status = "running"
m.rows[i].Err = ""
break
}
}
}
}
if len(targets) == 0 {
m.status = "no errored rows to retry"
return nil
}
ctx, cancel := context.WithTimeout(context.Background(), 3*time.Minute)
m.cancel = cancel
runner := scan.NewRunner(m.db, m.cfg)
m.resultsCh = runner.Run(ctx, targets)
m.running = true
m.status = fmt.Sprintf("retrying %d errored rows…", len(targets))
m.refreshTable()
return m.pump()
}
func (m *ScanModel) pump() tea.Cmd {
return func() tea.Msg {
res, ok := <-m.resultsCh
if !ok {
return scanDoneMsg{}
}
return scanResultMsg(res)
}
}
func (m *ScanModel) applyResult(res scan.ScanResult) {
for i := range m.rows {
if m.rows[i].Name != res.Company {
continue
}
if res.Err != nil {
if scan.IsUnsupported(res.Err) {
m.rows[i].Status = "unsupported"
} else {
m.rows[i].Status = "error"
m.rows[i].Err = res.Err.Error()
}
return
}
m.rows[i].Status = "done"
m.rows[i].Found = res.Found
m.rows[i].Filtered = res.Filtered
m.rows[i].Duplicate = res.Duplicate
m.rows[i].New = res.New
return
}
}
func (m *ScanModel) refreshTable() {
newRows := make([]table.Row, 0, len(m.rows))
for _, r := range m.rows {
notes := r.Err
if notes == "" && r.Status == "unsupported" {
notes = "no tier-1 API"
}
newRows = append(newRows, table.Row{
r.Name, r.Status, intStr(r.Found), intStr(r.Filtered),
intStr(r.Duplicate), intStr(r.New), notes,
})
}
m.table.SetRows(newRows)
}
func (m *ScanModel) summary() string {
var found, newCnt, dup, err, unsup int
for _, r := range m.rows {
found += r.Found
newCnt += r.New
dup += r.Duplicate
switch r.Status {
case "error":
err++
case "unsupported":
unsup++
}
}
return fmt.Sprintf("done — %d found · %d new · %d dup · %d errors · %d unsupported", found, newCnt, dup, err, unsup)
}
func (m *ScanModel) View() string {
if m.cfg == nil {
return StyleTitle.Render("Scan") + "\n" + StyleStatusErr.Render(m.status)
}
header := StyleTitle.Render("Scan — Tier-1 API adapters")
sub := StyleSubtitle.Render(fmt.Sprintf("%d companies · [s] scan · [r] retry errored · [enter] detail · [esc] cancel", len(m.rows)))
status := StyleStatusMuted.Render(m.status)
view := lipgloss.JoinVertical(lipgloss.Left, header, sub, "", m.table.View(), "", status)
if m.showDetail && m.detailIdx < len(m.rows) {
overlay := m.renderDetail(m.rows[m.detailIdx])
return lipgloss.JoinVertical(lipgloss.Left, view, overlay)
}
return view
}
func (m *ScanModel) renderDetail(r scanRow) string {
var b strings.Builder
b.WriteString(StyleTitle.Render("Row detail — " + r.Name))
b.WriteString("\n\n")
b.WriteString(kv("Status", r.Status))
b.WriteString(kv("Found", fmt.Sprintf("%d", r.Found)))
b.WriteString(kv("Filtered", fmt.Sprintf("%d", r.Filtered)))
b.WriteString(kv("Duplicate", fmt.Sprintf("%d", r.Duplicate)))
b.WriteString(kv("New", fmt.Sprintf("%d", r.New)))
if r.Err != "" {
b.WriteString("\n")
b.WriteString(StyleStatusErr.Render("Error: " + r.Err))
b.WriteString("\n")
}
b.WriteString("\n" + StyleKeyDesc.Render("press enter or esc to close"))
return StyleModal.Render(b.String())
}
func kv(k, v string) string {
return StyleKey.Render(fmt.Sprintf("%-12s", k)) + StyleKeyDesc.Render(v) + "\n"
}