package tui import ( "database/sql" "fmt" "os" "os/exec" "strings" "time" "github.com/charmbracelet/bubbles/table" "github.com/charmbracelet/bubbles/textinput" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" ) type PipelineModel struct { db *sql.DB table table.Model rows []pipelineRow filteredIdx []int width int height int status string mode pipelineMode filterInput textinput.Model } type pipelineMode int const ( pmList pipelineMode = iota pmFilter ) type pipelineRow struct { ID int JobID int Company string Title string QueuedAt time.Time StartedAt *time.Time CompletedAt *time.Time } func (r *pipelineRow) Status() string { if r.CompletedAt != nil { return "done" } if r.StartedAt != nil { return "running" } return "pending" } func NewPipelineModel(db *sql.DB) *PipelineModel { ti := textinput.New() ti.Placeholder = "filter by company or title…" ti.CharLimit = 100 ti.Width = 60 m := &PipelineModel{ db: db, filterInput: ti, status: "[/] filter [d] dequeue [r] report [s] advance status [q/esc] clear", } m.loadRows() m.applyFilter("") m.table = newPipelineTable(m.visibleRows(), 0, 0) return m } func (m *PipelineModel) loadRows() { m.rows = nil if m.db == nil { m.status = "no database" return } rows, err := m.db.Query(` SELECT p.id, p.job_id, j.company, j.title, p.queued_at, p.started_at, p.completed_at FROM pipeline p JOIN jobs j ON j.id = p.job_id ORDER BY p.queued_at DESC `) if err != nil { m.status = fmt.Sprintf("query failed: %v", err) return } defer rows.Close() for rows.Next() { var r pipelineRow if err := rows.Scan(&r.ID, &r.JobID, &r.Company, &r.Title, &r.QueuedAt, &r.StartedAt, &r.CompletedAt); err != nil { continue } m.rows = append(m.rows, r) } } func pipelineColumns() []table.Column { return []table.Column{ {Title: "ID", Width: 4}, {Title: "Company", Width: 20}, {Title: "Title", Width: 30}, {Title: "Queued", Width: 10}, {Title: "Status", Width: 9}, } } func newPipelineTable(rows []pipelineRow, w, h int) table.Model { cols := flexColumns(w, pipelineColumns(), []int{1, 2}) trows := make([]table.Row, 0, len(rows)) for _, r := range rows { trows = append(trows, pipelineRowToTable(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 pipelineRowToTable(r pipelineRow) table.Row { return table.Row{ fmt.Sprintf("%d", r.ID), r.Company, r.Title, r.QueuedAt.Local().Format("2006-01-02"), r.Status(), } } func (m *PipelineModel) visibleRows() []pipelineRow { if len(m.filteredIdx) == 0 { return m.rows } result := make([]pipelineRow, 0, len(m.filteredIdx)) for _, i := range m.filteredIdx { if i >= 0 && i < len(m.rows) { result = append(result, m.rows[i]) } } return result } func (m *PipelineModel) applyFilter(query string) { m.filteredIdx = nil if query == "" { return } lower := strings.ToLower(query) for i, r := range m.rows { if strings.Contains(strings.ToLower(r.Company), lower) || strings.Contains(strings.ToLower(r.Title), lower) { m.filteredIdx = append(m.filteredIdx, i) } } } func (m *PipelineModel) selectedRowIndex() int { cur := m.table.Cursor() visible := m.visibleRows() if cur >= len(visible) { return -1 } if len(m.filteredIdx) == 0 { return cur } return m.filteredIdx[cur] } func (m *PipelineModel) Init() tea.Cmd { return nil } func (m *PipelineModel) 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, pipelineColumns(), []int{1, 2})) } func (m *PipelineModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if m.mode == pmFilter { return m.updateFilter(msg) } switch msg := msg.(type) { case tea.KeyMsg: switch msg.String() { case "/": m.filterInput.SetValue("") m.filterInput.Focus() m.mode = pmFilter m.status = "type to filter, enter to apply, esc to cancel" case "d": if idx := m.selectedRowIndex(); idx >= 0 { return m, m.dequeueItem(m.rows[idx].ID) } case "r": if idx := m.selectedRowIndex(); idx >= 0 { m.openReport(m.rows[idx].JobID) } case "s": if idx := m.selectedRowIndex(); idx >= 0 { return m, m.advanceStatus(m.rows[idx].JobID) } case "q", "esc": if len(m.filteredIdx) > 0 { m.filteredIdx = nil m.applyFilter("") m.refreshTable() m.status = "filter cleared" } } } var cmd tea.Cmd m.table, cmd = m.table.Update(msg) return m, cmd } func (m *PipelineModel) updateFilter(msg tea.Msg) (tea.Model, tea.Cmd) { if k, ok := msg.(tea.KeyMsg); ok { switch k.String() { case "esc": m.mode = pmList m.status = "[/] filter [d] dequeue [r] report [s] advance status [q/esc] clear" return m, nil case "enter": query := strings.TrimSpace(m.filterInput.Value()) m.applyFilter(query) m.table.SetCursor(0) m.refreshTable() m.mode = pmList pending := m.countStatus("pending") m.status = fmt.Sprintf("%d items (filtered) · %d pending", len(m.visibleRows()), pending) return m, nil } } var cmd tea.Cmd m.filterInput, cmd = m.filterInput.Update(msg) return m, cmd } func (m *PipelineModel) dequeueItem(id int) tea.Cmd { return func() tea.Msg { _, err := m.db.Exec(`DELETE FROM pipeline WHERE id = ?`, id) if err != nil { m.status = fmt.Sprintf("dequeue failed: %v", err) } else { m.loadRows() m.applyFilter(m.filterInput.Value()) m.refreshTable() m.status = fmt.Sprintf("removed #%d from pipeline", id) } return nil } } func (m *PipelineModel) openReport(jobID int) { if m.db == nil { return } var path string err := m.db.QueryRow(` SELECT r.file_path FROM reports r JOIN applications a ON a.id = r.application_id WHERE a.job_id = ? ORDER BY r.id DESC LIMIT 1 `, jobID).Scan(&path) if err != nil { m.status = fmt.Sprintf("no report found for job #%d", jobID) return } 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 *PipelineModel) advanceStatus(jobID int) tea.Cmd { return func() tea.Msg { var status string err := m.db.QueryRow(`SELECT status FROM applications WHERE job_id = ? ORDER BY id DESC LIMIT 1`, jobID).Scan(&status) if err == sql.ErrNoRows { m.status = fmt.Sprintf("no application found for job #%d", jobID) return nil } if err != nil { m.status = fmt.Sprintf("query failed: %v", err) return nil } if status != "Queued" { m.status = fmt.Sprintf("job #%d is %s, not Queued; no advance possible", jobID, status) return nil } _, err = m.db.Exec(`UPDATE applications SET status = ? WHERE job_id = ?`, "Evaluated", jobID) if err != nil { m.status = fmt.Sprintf("advance failed: %v", err) } else { m.status = fmt.Sprintf("job #%d advanced to Evaluated", jobID) } return nil } } func (m *PipelineModel) countStatus(s string) int { count := 0 for _, r := range m.visibleRows() { if r.Status() == s { count++ } } return count } func (m *PipelineModel) refreshTable() { trows := make([]table.Row, 0, len(m.visibleRows())) for _, r := range m.visibleRows() { trows = append(trows, pipelineRowToTable(r)) } m.table.SetRows(trows) } func (m *PipelineModel) View() string { header := StyleTitle.Render("Pipeline — batch evaluation queue") pending := m.countStatus("pending") sub := StyleSubtitle.Render(fmt.Sprintf("%d items (%d pending) · [/] filter · [d] dequeue · [r] report · [s] advance · [q/esc] clear", len(m.visibleRows()), pending)) var body string if m.mode == pmFilter { body = m.renderFilterPrompt() } else { body = m.table.View() } status := StyleStatusMuted.Render(m.status) return lipgloss.JoinVertical(lipgloss.Left, header, sub, "", body, "", status) } func (m *PipelineModel) renderFilterPrompt() string { var b strings.Builder b.WriteString("Filter by company or title:\n\n") b.WriteString(m.filterInput.View()) b.WriteString("\n\n" + StyleKeyDesc.Render("[enter] apply · [esc] cancel")) return StyleModal.Render(b.String()) }