initial public release
This commit is contained in:
@@ -0,0 +1,271 @@
|
||||
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()
|
||||
}
|
||||
Reference in New Issue
Block a user