Files
2026-07-06 11:05:50 -04:00

272 lines
6.2 KiB
Go

package tui
import (
"fmt"
"os"
"path/filepath"
"strings"
"github.com/cobr-ai/apex/internal/intake"
"github.com/cobr-ai/apex/internal/store"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
)
const (
TabIntake = "Intake"
TabScan = "Scan"
TabEvaluate = "Evaluate"
TabTracker = "Tracker"
TabPipeline = "Pipeline"
TabReports = "Reports"
TabSettings = "Settings"
)
type tabbedModel interface {
tea.Model
Resize(width, height int)
}
type App struct {
activeTab string
cursorTab int
tabs []string
models map[string]tea.Model
db *store.DB
dims layoutDims
showHelp bool
errBanner string
}
func NewApp() *App {
app := &App{
activeTab: TabIntake,
tabs: []string{TabIntake, TabScan, TabEvaluate, TabTracker, TabPipeline, TabReports, TabSettings},
models: make(map[string]tea.Model),
}
homeDir, err := os.UserHomeDir()
if err != nil {
homeDir = "/tmp"
}
dbPath := filepath.Join(homeDir, ".apex", "apex.db")
os.MkdirAll(filepath.Dir(dbPath), 0755)
db, err := store.NewDB(dbPath)
if err != nil {
app.errBanner = fmt.Sprintf("database init failed: %v", err)
app.models[TabIntake] = NewPlaceholder("Intake — database unavailable")
app.models[TabScan] = NewPlaceholder("Scan — database unavailable")
app.models[TabEvaluate] = NewPlaceholder("Evaluate — database unavailable")
app.models[TabTracker] = NewPlaceholder("Tracker — database unavailable")
app.models[TabPipeline] = NewPlaceholder("Pipeline — database unavailable")
app.models[TabReports] = NewPlaceholder("Reports — database unavailable")
app.models[TabSettings] = NewPlaceholder("Settings — database unavailable")
return app
}
app.db = db
manager := intake.NewManager(db.Conn())
app.models[TabIntake] = NewIntakeModel(manager)
scanCfgPath := filepath.Join(findRepoRoot(), "internal", "scan", "adapters.yaml")
app.models[TabScan] = NewScanModel(scanCfgPath, app.db.Conn())
cvPath := filepath.Join(homeDir, ".apex", "cv.md")
app.models[TabEvaluate] = NewEvalModel(app.db.Conn(), cvPath)
app.models[TabTracker] = NewTrackerModel(app.db.Conn())
app.models[TabPipeline] = NewPipelineModel(app.db.Conn())
app.models[TabReports] = NewReportsModel(app.db.Conn())
app.models[TabSettings] = NewSettingsModel(app.db.Conn(), scanCfgPath)
return app
}
func (a *App) Run() error {
p := tea.NewProgram(a, tea.WithAltScreen())
_, err := p.Run()
return err
}
func (a *App) Init() tea.Cmd {
cmds := []tea.Cmd{}
for _, m := range a.models {
if m != nil {
if c := m.Init(); c != nil {
cmds = append(cmds, c)
}
}
}
return tea.Batch(cmds...)
}
func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
a.dims = computeLayout(msg.Width, msg.Height)
for _, name := range a.tabs {
if tm, ok := a.models[name].(tabbedModel); ok {
tm.Resize(a.dims.contentW, a.dims.contentH)
}
}
case tea.KeyMsg:
key := msg.String()
if a.showHelp {
switch key {
case "?", "esc", "q":
a.showHelp = false
return a, nil
}
return a, nil
}
switch key {
case "ctrl+c":
return a, tea.Quit
case "?":
a.showHelp = true
return a, nil
case "tab", "down":
a.cursorTab = (a.cursorTab + 1) % len(a.tabs)
a.activeTab = a.tabs[a.cursorTab]
return a, nil
case "shift+tab", "up":
a.cursorTab = (a.cursorTab - 1 + len(a.tabs)) % len(a.tabs)
a.activeTab = a.tabs[a.cursorTab]
return a, nil
}
}
model, cmd := a.models[a.activeTab].Update(msg)
a.models[a.activeTab] = model
return a, cmd
}
func (a *App) View() string {
if a.dims.termW == 0 {
return "initializing…"
}
title := StyleTitle.Render("apex") + " " + StyleSubtitle.Render("// job-search platform")
sidebar := a.renderSidebar()
content := a.renderContent()
body := lipgloss.JoinHorizontal(lipgloss.Top, sidebar, content)
footer := a.renderFooter()
view := lipgloss.JoinVertical(lipgloss.Left, title, body, footer)
if a.showHelp {
overlay := a.renderHelp()
return placeOverlay(view, overlay, a.dims.termW, a.dims.termH)
}
return view
}
func (a *App) renderSidebar() string {
var items []string
for _, t := range a.tabs {
if t == a.activeTab {
items = append(items, StyleSidebarActive.Render("▌ "+t))
} else {
items = append(items, StyleSidebarItem.Render(" "+t))
}
}
inner := lipgloss.JoinVertical(lipgloss.Left, items...)
return StyleSidebarBox.
Width(sidebarWidth).
Height(a.dims.sidebarH).
Render(inner)
}
func (a *App) renderContent() string {
m := a.models[a.activeTab]
body := ""
if m != nil {
body = m.View()
}
return StyleContentBox.
Width(a.dims.contentW).
Height(a.dims.contentH).
Render(body)
}
func (a *App) renderFooter() string {
parts := []string{}
add := func(k, d string) {
parts = append(parts, StyleKey.Render(k)+" "+StyleKeyDesc.Render(d))
}
add("↑/↓", "tab")
add("j/k", "row")
add("?", "help")
add("q", "quit")
if bindings, ok := a.activeTabBindings(); ok {
parts = append(parts, StyleKeyDesc.Render("│"))
for _, kb := range bindings {
add(kb.Key, kb.Desc)
}
}
line := strings.Join(parts, " ")
if a.errBanner != "" {
line = StyleStatusErr.Render(a.errBanner) + " " + line
}
return StyleFooter.Render(line)
}
func placeOverlay(bg, fg string, w, h int) string {
bgLines := strings.Split(bg, "\n")
fgLines := strings.Split(fg, "\n")
fgH := len(fgLines)
fgW := 0
for _, l := range fgLines {
if ll := lipgloss.Width(l); ll > fgW {
fgW = ll
}
}
top := (h - fgH) / 2
left := (w - fgW) / 2
if top < 0 {
top = 0
}
if left < 0 {
left = 0
}
out := make([]string, len(bgLines))
copy(out, bgLines)
for i, fl := range fgLines {
y := top + i
if y < 0 || y >= len(out) {
continue
}
padded := padAnsiLeft(out[y], left) + fl
out[y] = padded
}
return strings.Join(out, "\n")
}
func padAnsiLeft(s string, col int) string {
w := lipgloss.Width(s)
if w >= col {
return truncDisplay(s, col)
}
return s + strings.Repeat(" ", col-w)
}
func truncDisplay(s string, n int) string {
if lipgloss.Width(s) <= n {
return s
}
runes := []rune(s)
buf := strings.Builder{}
width := 0
for _, r := range runes {
cell := 1
if r > 0x2e80 {
cell = 2
}
if width+cell > n {
break
}
buf.WriteRune(r)
width += cell
}
return buf.String()
}