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" }