272 lines
6.2 KiB
Go
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()
|
|
}
|