wrds-download

TUI/CLI tool for browsing and downloading WRDS data
Log | Files | Refs | README

app.go (24457B)


      1 package tui
      2 
      3 import (
      4 	"context"
      5 	"fmt"
      6 	"os"
      7 	"strings"
      8 	"time"
      9 
     10 	"github.com/charmbracelet/bubbles/list"
     11 	"github.com/charmbracelet/bubbles/spinner"
     12 	"github.com/charmbracelet/bubbles/textinput"
     13 	tea "github.com/charmbracelet/bubbletea"
     14 	"github.com/charmbracelet/lipgloss"
     15 	"github.com/louloulibs/wrds-download/internal/config"
     16 	"github.com/louloulibs/wrds-download/internal/db"
     17 	"github.com/louloulibs/wrds-download/internal/export"
     18 )
     19 
     20 // pane identifies which panel is focused.
     21 type pane int
     22 
     23 const (
     24 	paneSchema pane = iota
     25 	paneTable
     26 	panePreview
     27 )
     28 
     29 // appState represents the TUI state machine.
     30 type appState int
     31 
     32 const (
     33 	stateLogin appState = iota
     34 	stateBrowse
     35 	stateDatabaseSelect
     36 	stateDownloadForm
     37 	stateDownloading
     38 	stateDone
     39 )
     40 
     41 // -- Tea messages --
     42 
     43 type schemasLoadedMsg struct{ schemas []db.Schema }
     44 type tablesLoadedMsg struct{ tables []db.Table }
     45 type metaLoadedMsg struct{ meta *db.TableMeta }
     46 type errMsg struct{ err error }
     47 type downloadDoneMsg struct{ path string }
     48 type downloadProgressMsg struct{ rows int }
     49 type tickMsg time.Time
     50 type loginSuccessMsg struct{ client *db.Client }
     51 type loginFailMsg struct{ err error }
     52 type databasesLoadedMsg struct{ databases []string }
     53 type databaseSwitchedMsg struct{ client *db.Client }
     54 type databaseSwitchFailMsg struct{ err error }
     55 
     56 func errCmd(err error) tea.Cmd {
     57 	return func() tea.Msg { return errMsg{err} }
     58 }
     59 
     60 // item wraps a string to satisfy the bubbles list.Item interface.
     61 type item struct{ title string }
     62 
     63 func (i item) FilterValue() string { return i.title }
     64 func (i item) Title() string       { return i.title }
     65 func (i item) Description() string { return "" }
     66 
     67 // App is the root Bubble Tea model.
     68 type App struct {
     69 	client *db.Client
     70 
     71 	width, height int
     72 	focus         pane
     73 	state         appState
     74 
     75 	schemaList       list.Model
     76 	tableList        list.Model
     77 	previewMeta      *db.TableMeta
     78 	previewScroll    int
     79 	previewFilter    textinput.Model
     80 	previewFiltering bool
     81 
     82 	loginForm LoginForm
     83 	loginErr  string
     84 	dlForm    DlForm
     85 	dbList    list.Model
     86 	spinner   spinner.Model
     87 	statusOK  string
     88 	statusErr string
     89 
     90 	currentDatabase string
     91 	selectedSchema  string
     92 	selectedTable   string
     93 	downloadRows    int
     94 	dlProgressCh    chan int
     95 }
     96 
     97 func newPreviewFilter() textinput.Model {
     98 	pf := textinput.New()
     99 	pf.Prompt = "/ "
    100 	pf.Placeholder = "filter columns…"
    101 	pf.CharLimit = 64
    102 	return pf
    103 }
    104 
    105 // NewApp constructs the root model.
    106 func NewApp(client *db.Client) *App {
    107 	del := list.NewDefaultDelegate()
    108 	del.ShowDescription = false
    109 	del.SetSpacing(0)
    110 
    111 	schemaList := list.New(nil, del, 0, 0)
    112 	schemaList.Title = "Schemas"
    113 	schemaList.SetShowStatusBar(false)
    114 	schemaList.SetFilteringEnabled(true)
    115 	schemaList.DisableQuitKeybindings()
    116 	schemaList.Styles.TitleBar = schemaList.Styles.TitleBar.Padding(0, 0, 0, 2)
    117 
    118 	tableList := list.New(nil, del, 0, 0)
    119 	tableList.Title = "Tables"
    120 	tableList.SetShowStatusBar(false)
    121 	tableList.SetFilteringEnabled(true)
    122 	tableList.DisableQuitKeybindings()
    123 	tableList.Styles.TitleBar = tableList.Styles.TitleBar.Padding(0, 0, 0, 2)
    124 
    125 	dbList := list.New(nil, del, 0, 0)
    126 	dbList.Title = "Databases"
    127 	dbList.SetShowStatusBar(false)
    128 	dbList.SetFilteringEnabled(true)
    129 	dbList.DisableQuitKeybindings()
    130 	dbList.Styles.TitleBar = dbList.Styles.TitleBar.Padding(0, 0, 0, 2)
    131 
    132 	sp := spinner.New()
    133 	sp.Spinner = spinner.Dot
    134 
    135 	return &App{
    136 		client:          client,
    137 		currentDatabase: os.Getenv("PGDATABASE"),
    138 		schemaList:      schemaList,
    139 		tableList:       tableList,
    140 		dbList:          dbList,
    141 		spinner:         sp,
    142 		previewFilter:   newPreviewFilter(),
    143 		focus:           paneSchema,
    144 		state:           stateBrowse,
    145 	}
    146 }
    147 
    148 // NewAppNoClient creates an App in login state (no DB connection yet).
    149 func NewAppNoClient() *App {
    150 	del := list.NewDefaultDelegate()
    151 	del.ShowDescription = false
    152 	del.SetSpacing(0)
    153 
    154 	schemaList := list.New(nil, del, 0, 0)
    155 	schemaList.Title = "Schemas"
    156 	schemaList.SetShowStatusBar(false)
    157 	schemaList.SetFilteringEnabled(true)
    158 	schemaList.DisableQuitKeybindings()
    159 	schemaList.Styles.TitleBar = schemaList.Styles.TitleBar.Padding(0, 0, 0, 2)
    160 
    161 	tableList := list.New(nil, del, 0, 0)
    162 	tableList.Title = "Tables"
    163 	tableList.SetShowStatusBar(false)
    164 	tableList.SetFilteringEnabled(true)
    165 	tableList.DisableQuitKeybindings()
    166 	tableList.Styles.TitleBar = tableList.Styles.TitleBar.Padding(0, 0, 0, 2)
    167 
    168 	dbList := list.New(nil, del, 0, 0)
    169 	dbList.Title = "Databases"
    170 	dbList.SetShowStatusBar(false)
    171 	dbList.SetFilteringEnabled(true)
    172 	dbList.DisableQuitKeybindings()
    173 	dbList.Styles.TitleBar = dbList.Styles.TitleBar.Padding(0, 0, 0, 2)
    174 
    175 	sp := spinner.New()
    176 	sp.Spinner = spinner.Dot
    177 
    178 	return &App{
    179 		schemaList:    schemaList,
    180 		tableList:     tableList,
    181 		dbList:        dbList,
    182 		spinner:       sp,
    183 		previewFilter: newPreviewFilter(),
    184 		focus:         paneSchema,
    185 		state:         stateLogin,
    186 		loginForm:     newLoginForm(),
    187 	}
    188 }
    189 
    190 // Init loads schemas on startup, or starts login form blink if in login state.
    191 func (a *App) Init() tea.Cmd {
    192 	if a.state == stateLogin {
    193 		return textinput.Blink
    194 	}
    195 	return tea.Batch(
    196 		a.loadSchemas(),
    197 		a.spinner.Tick,
    198 	)
    199 }
    200 
    201 func (a *App) loadSchemas() tea.Cmd {
    202 	return func() tea.Msg {
    203 		schemas, err := a.client.Schemas(context.Background())
    204 		if err != nil {
    205 			return errMsg{err}
    206 		}
    207 		return schemasLoadedMsg{schemas}
    208 	}
    209 }
    210 
    211 func (a *App) loadTables(schema string) tea.Cmd {
    212 	return func() tea.Msg {
    213 		tables, err := a.client.Tables(context.Background(), schema)
    214 		if err != nil {
    215 			return errMsg{err}
    216 		}
    217 		return tablesLoadedMsg{tables}
    218 	}
    219 }
    220 
    221 func (a *App) loadMeta(schema, tbl string) tea.Cmd {
    222 	return func() tea.Msg {
    223 		ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
    224 		defer cancel()
    225 		meta, err := a.client.TableMeta(ctx, schema, tbl)
    226 		if err != nil {
    227 			return errMsg{err}
    228 		}
    229 		return metaLoadedMsg{meta}
    230 	}
    231 }
    232 
    233 func (a *App) startDownload(msg DlSubmitMsg) tea.Cmd {
    234 	progressCh := make(chan int, 1)
    235 	a.dlProgressCh = progressCh
    236 
    237 	download := func() tea.Msg {
    238 		sel := "*"
    239 		if msg.Columns != "" && msg.Columns != "*" {
    240 			parts := strings.Split(msg.Columns, ",")
    241 			quoted := make([]string, len(parts))
    242 			for i, p := range parts {
    243 				quoted[i] = db.QuoteIdent(strings.TrimSpace(p))
    244 			}
    245 			sel = strings.Join(quoted, ", ")
    246 		}
    247 		query := fmt.Sprintf("SELECT %s FROM %s.%s", sel, db.QuoteIdent(msg.Schema), db.QuoteIdent(msg.Table))
    248 		if msg.Where != "" {
    249 			query += " WHERE " + msg.Where
    250 		}
    251 		if msg.Limit > 0 {
    252 			query += fmt.Sprintf(" LIMIT %d", msg.Limit)
    253 		}
    254 		opts := export.Options{
    255 			Format: msg.Format,
    256 			ProgressFunc: func(rows int) {
    257 				select {
    258 				case progressCh <- rows:
    259 				default:
    260 				}
    261 			},
    262 		}
    263 		err := export.Export(query, msg.Out, opts)
    264 		close(progressCh)
    265 		if err != nil {
    266 			return errMsg{err}
    267 		}
    268 		return downloadDoneMsg{msg.Out}
    269 	}
    270 
    271 	listenProgress := func() tea.Msg {
    272 		rows, ok := <-progressCh
    273 		if !ok {
    274 			return nil
    275 		}
    276 		return downloadProgressMsg{rows}
    277 	}
    278 
    279 	return tea.Batch(download, listenProgress)
    280 }
    281 
    282 func (a *App) attemptLogin(msg LoginSubmitMsg) tea.Cmd {
    283 	return func() tea.Msg {
    284 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    285 		defer cancel()
    286 		client, err := db.NewWithCredentials(ctx, msg.User, msg.Password, msg.Database)
    287 		if err != nil {
    288 			return loginFailMsg{err}
    289 		}
    290 		if msg.Save {
    291 			_ = config.SaveCredentials(msg.User, msg.Password, msg.Database)
    292 		}
    293 		return loginSuccessMsg{client}
    294 	}
    295 }
    296 
    297 func (a *App) loadDatabases() tea.Cmd {
    298 	return func() tea.Msg {
    299 		dbs, err := a.client.Databases(context.Background())
    300 		if err != nil {
    301 			return errMsg{err}
    302 		}
    303 		return databasesLoadedMsg{dbs}
    304 	}
    305 }
    306 
    307 func (a *App) switchDatabase(name string) tea.Cmd {
    308 	return func() tea.Msg {
    309 		ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
    310 		defer cancel()
    311 		user := os.Getenv("PGUSER")
    312 		password := os.Getenv("PGPASSWORD")
    313 		client, err := db.NewWithCredentials(ctx, user, password, name)
    314 		if err != nil {
    315 			return databaseSwitchFailMsg{err}
    316 		}
    317 		return databaseSwitchedMsg{client}
    318 	}
    319 }
    320 
    321 // friendlyError extracts a short, readable message from verbose pgx errors.
    322 func friendlyError(err error) string {
    323 	s := err.Error()
    324 	// pgx errors look like: "ping: failed to connect to `host=... user=...`: <reason>"
    325 	// Extract just the reason after the last colon-space following the backtick-quoted section.
    326 	if idx := strings.LastIndex(s, "`: "); idx != -1 {
    327 		return s[idx+3:]
    328 	}
    329 	// Fall back to stripping common prefixes.
    330 	for _, prefix := range []string{"ping: ", "pgxpool.New: "} {
    331 		s = strings.TrimPrefix(s, prefix)
    332 	}
    333 	return s
    334 }
    335 
    336 // Update handles all messages.
    337 func (a *App) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
    338 	switch msg := msg.(type) {
    339 
    340 	case tea.WindowSizeMsg:
    341 		a.width = msg.Width
    342 		a.height = msg.Height
    343 		a.resizePanels()
    344 		return a, nil
    345 
    346 	case spinner.TickMsg:
    347 		var cmd tea.Cmd
    348 		a.spinner, cmd = a.spinner.Update(msg)
    349 		return a, cmd
    350 
    351 	case schemasLoadedMsg:
    352 		items := make([]list.Item, len(msg.schemas))
    353 		for i, s := range msg.schemas {
    354 			items[i] = item{s.Name}
    355 		}
    356 		a.schemaList.SetItems(items)
    357 		return a, nil
    358 
    359 	case tablesLoadedMsg:
    360 		items := make([]list.Item, len(msg.tables))
    361 		for i, t := range msg.tables {
    362 			items[i] = item{t.Name}
    363 		}
    364 		a.tableList.SetItems(items)
    365 		a.previewMeta = nil
    366 		a.previewScroll = 0
    367 		a.previewFilter.SetValue("")
    368 		a.previewFiltering = false
    369 		return a, nil
    370 
    371 	case metaLoadedMsg:
    372 		a.previewMeta = msg.meta
    373 		a.previewScroll = 0
    374 		a.previewFilter.SetValue("")
    375 		a.previewFiltering = false
    376 		return a, nil
    377 
    378 	case LoginSubmitMsg:
    379 		a.loginErr = ""
    380 		return a, a.attemptLogin(msg)
    381 
    382 	case LoginCancelMsg:
    383 		return a, tea.Quit
    384 
    385 	case loginSuccessMsg:
    386 		a.client = msg.client
    387 		a.currentDatabase = os.Getenv("PGDATABASE")
    388 		a.state = stateBrowse
    389 		return a, tea.Batch(a.loadSchemas(), a.spinner.Tick)
    390 
    391 	case loginFailMsg:
    392 		a.loginErr = friendlyError(msg.err)
    393 		a.state = stateLogin
    394 		return a, nil
    395 
    396 	case databasesLoadedMsg:
    397 		items := make([]list.Item, len(msg.databases))
    398 		for i, d := range msg.databases {
    399 			items[i] = item{d}
    400 		}
    401 		a.dbList.SetItems(items)
    402 		a.state = stateDatabaseSelect
    403 		return a, nil
    404 
    405 	case databaseSwitchedMsg:
    406 		a.client.Close()
    407 		a.client = msg.client
    408 		a.currentDatabase = os.Getenv("PGDATABASE")
    409 		a.selectedSchema = ""
    410 		a.selectedTable = ""
    411 		a.previewMeta = nil
    412 		a.previewScroll = 0
    413 		a.previewFilter.SetValue("")
    414 		a.tableList.SetItems(nil)
    415 		a.state = stateBrowse
    416 		return a, a.loadSchemas()
    417 
    418 	case databaseSwitchFailMsg:
    419 		a.statusErr = friendlyError(msg.err)
    420 		a.state = stateBrowse
    421 		return a, nil
    422 
    423 	case errMsg:
    424 		a.statusErr = friendlyError(msg.err)
    425 		a.state = stateBrowse
    426 		return a, nil
    427 
    428 	case downloadProgressMsg:
    429 		a.downloadRows = msg.rows
    430 		// Keep listening for more progress updates.
    431 		ch := a.dlProgressCh
    432 		return a, func() tea.Msg {
    433 			rows, ok := <-ch
    434 			if !ok {
    435 				return nil
    436 			}
    437 			return downloadProgressMsg{rows}
    438 		}
    439 
    440 	case downloadDoneMsg:
    441 		a.statusOK = fmt.Sprintf("Saved: %s", msg.path)
    442 		a.state = stateDone
    443 		a.downloadRows = 0
    444 		return a, nil
    445 
    446 	case DlCancelMsg:
    447 		a.state = stateBrowse
    448 		return a, nil
    449 
    450 	case DlSubmitMsg:
    451 		a.state = stateDownloading
    452 		a.statusErr = ""
    453 		a.statusOK = ""
    454 		a.downloadRows = 0
    455 		return a, tea.Batch(a.startDownload(msg), a.spinner.Tick)
    456 
    457 	case list.FilterMatchesMsg:
    458 		// Route async filter results back to the list that initiated filtering.
    459 		var cmd tea.Cmd
    460 		switch {
    461 		case a.schemaList.FilterState() == list.Filtering:
    462 			a.schemaList, cmd = a.schemaList.Update(msg)
    463 		case a.tableList.FilterState() == list.Filtering:
    464 			a.tableList, cmd = a.tableList.Update(msg)
    465 		case a.dbList.FilterState() == list.Filtering:
    466 			a.dbList, cmd = a.dbList.Update(msg)
    467 		}
    468 		return a, cmd
    469 
    470 	case tea.KeyMsg:
    471 		if a.state == stateLogin {
    472 			var cmd tea.Cmd
    473 			a.loginForm, cmd = a.loginForm.Update(msg)
    474 			return a, cmd
    475 		}
    476 		if a.state == stateDownloadForm {
    477 			var cmd tea.Cmd
    478 			a.dlForm, cmd = a.dlForm.Update(msg)
    479 			return a, cmd
    480 		}
    481 		if a.state == stateDatabaseSelect {
    482 			if a.dbList.FilterState() == list.Filtering {
    483 				var cmd tea.Cmd
    484 				a.dbList, cmd = a.dbList.Update(msg)
    485 				return a, cmd
    486 			}
    487 			switch msg.String() {
    488 			case "esc":
    489 				a.state = stateBrowse
    490 				return a, nil
    491 			case "enter":
    492 				if sel := selectedItemTitle(a.dbList); sel != "" {
    493 					a.state = stateDownloading
    494 					return a, tea.Batch(a.switchDatabase(sel), a.spinner.Tick)
    495 				}
    496 			}
    497 			var cmd tea.Cmd
    498 			a.dbList, cmd = a.dbList.Update(msg)
    499 			return a, cmd
    500 		}
    501 
    502 		// Preview column filter: intercept all keys when active.
    503 		if a.focus == panePreview && a.previewFiltering {
    504 			switch msg.String() {
    505 			case "esc":
    506 				a.previewFiltering = false
    507 				a.previewFilter.SetValue("")
    508 				a.previewFilter.Blur()
    509 				return a, nil
    510 			case "enter":
    511 				a.previewFiltering = false
    512 				a.previewFilter.Blur()
    513 				return a, nil
    514 			}
    515 			var cmd tea.Cmd
    516 			a.previewFilter, cmd = a.previewFilter.Update(msg)
    517 			a.previewScroll = 0
    518 			return a, cmd
    519 		}
    520 
    521 		switch msg.String() {
    522 		case "q", "ctrl+c":
    523 			if a.focusedListFiltering() {
    524 				break // let list handle it
    525 			}
    526 			return a, tea.Quit
    527 
    528 		case "tab":
    529 			if a.focusedListFiltering() {
    530 				break
    531 			}
    532 			a.statusErr = ""
    533 			a.focus = (a.focus + 1) % 3
    534 			return a, nil
    535 
    536 		case "shift+tab":
    537 			if a.focusedListFiltering() {
    538 				break
    539 			}
    540 			a.statusErr = ""
    541 			a.focus = (a.focus + 2) % 3
    542 			return a, nil
    543 
    544 		case "right", "l":
    545 			if a.focusedListFiltering() {
    546 				break
    547 			}
    548 			switch a.focus {
    549 			case paneSchema:
    550 				if sel := selectedItemTitle(a.schemaList); sel != "" {
    551 					a.selectedSchema = sel
    552 					a.selectedTable = ""
    553 					a.focus = paneTable
    554 					return a, a.loadTables(sel)
    555 				}
    556 			case paneTable:
    557 				if sel := selectedItemTitle(a.tableList); sel != "" {
    558 					a.selectedTable = sel
    559 					a.previewMeta = nil
    560 					a.previewScroll = 0
    561 					a.previewFilter.SetValue("")
    562 					a.focus = panePreview
    563 					return a, a.loadMeta(a.selectedSchema, sel)
    564 				}
    565 			}
    566 			return a, nil
    567 
    568 		case "left", "h":
    569 			if a.focusedListFiltering() {
    570 				break
    571 			}
    572 			if a.focus > paneSchema {
    573 				a.focus--
    574 			}
    575 			return a, nil
    576 
    577 		case "d":
    578 			if a.focusedListFiltering() {
    579 				break
    580 			}
    581 			if a.selectedSchema != "" && a.selectedTable != "" {
    582 				var colNames []string
    583 				if a.previewMeta != nil {
    584 					for _, c := range a.previewMeta.Columns {
    585 						colNames = append(colNames, c.Name)
    586 					}
    587 				}
    588 				a.dlForm = newDlForm(a.selectedSchema, a.selectedTable, colNames)
    589 				a.state = stateDownloadForm
    590 				return a, nil
    591 			}
    592 
    593 		case "b":
    594 			if a.focusedListFiltering() {
    595 				break
    596 			}
    597 			return a, a.loadDatabases()
    598 
    599 		case "esc":
    600 			if a.focusedListFiltering() {
    601 				break // let list cancel filter
    602 			}
    603 			if a.state == stateDone {
    604 				a.state = stateBrowse
    605 				a.statusOK = ""
    606 			}
    607 			return a, nil
    608 		}
    609 
    610 		// All other keys (including enter, /, letters) go to the focused list/pane.
    611 		var cmd tea.Cmd
    612 		switch a.focus {
    613 		case paneSchema:
    614 			a.schemaList, cmd = a.schemaList.Update(msg)
    615 		case paneTable:
    616 			a.tableList, cmd = a.tableList.Update(msg)
    617 		case panePreview:
    618 			switch msg.String() {
    619 			case "/":
    620 				a.previewFiltering = true
    621 				a.previewFilter.Focus()
    622 				cmd = textinput.Blink
    623 			case "j", "down":
    624 				cols := a.filteredColumns()
    625 				if a.previewScroll < len(cols)-1 {
    626 					a.previewScroll++
    627 				}
    628 			case "k", "up":
    629 				if a.previewScroll > 0 {
    630 					a.previewScroll--
    631 				}
    632 			}
    633 		}
    634 		return a, cmd
    635 	}
    636 
    637 	// Forward cursor blink messages to the active text input.
    638 	if a.previewFiltering {
    639 		var cmd tea.Cmd
    640 		a.previewFilter, cmd = a.previewFilter.Update(msg)
    641 		return a, cmd
    642 	}
    643 
    644 	// Forward spinner ticks when downloading.
    645 	if a.state == stateDownloading {
    646 		var cmd tea.Cmd
    647 		a.spinner, cmd = a.spinner.Update(msg)
    648 		return a, cmd
    649 	}
    650 
    651 	return a, nil
    652 }
    653 
    654 // View renders the full TUI.
    655 func (a *App) View() string {
    656 	if a.width == 0 {
    657 		return "Loading…"
    658 	}
    659 
    660 	if a.state == stateLogin {
    661 		return a.loginView()
    662 	}
    663 
    664 	dbLabel := ""
    665 	if a.currentDatabase != "" {
    666 		dbLabel = "  db:" + a.currentDatabase
    667 	}
    668 	header := styleTitle.Render(" WRDS") + styleStatusBar.Render("  Wharton Research Data Services"+dbLabel)
    669 	footer := a.footerView()
    670 
    671 	// Content area height.
    672 	contentH := a.height - lipgloss.Height(header) - lipgloss.Height(footer) - 2
    673 
    674 	schemaPanelW, tablePanelW, previewPanelW := a.panelWidths()
    675 
    676 	schemaPanel := a.renderListPanel(a.schemaList, "Schemas", paneSchema, schemaPanelW, contentH, 1)
    677 	tablePanel := a.renderListPanel(a.tableList, fmt.Sprintf("Tables (%s)", a.selectedSchema), paneTable, tablePanelW, contentH, 1)
    678 	previewPanel := a.renderPreviewPanel(previewPanelW, contentH)
    679 
    680 	body := lipgloss.JoinHorizontal(lipgloss.Top, schemaPanel, tablePanel, previewPanel)
    681 	full := lipgloss.JoinVertical(lipgloss.Left, header, body, footer)
    682 
    683 	if a.state == stateDatabaseSelect {
    684 		a.dbList.SetSize(40, a.height/2)
    685 		content := a.dbList.View()
    686 		hint := styleStatusBar.Render("[enter] switch   [esc] cancel   [/] filter")
    687 		box := lipgloss.NewStyle().
    688 			Border(lipgloss.RoundedBorder()).
    689 			BorderForeground(colorFocus).
    690 			Padding(1, 2).
    691 			Render(content + "\n" + hint)
    692 		return overlayCenter(full, box, a.width, a.height)
    693 	}
    694 	if a.state == stateDownloadForm {
    695 		overlay := a.dlForm.View(a.width)
    696 		return overlayCenter(full, overlay, a.width, a.height)
    697 	}
    698 	if a.state == stateDownloading {
    699 		dlMsg := a.spinner.View() + "  Downloading…"
    700 		if a.downloadRows > 0 {
    701 			dlMsg = a.spinner.View() + fmt.Sprintf("  Downloading… %s rows exported", formatCount(int64(a.downloadRows)))
    702 		}
    703 		return overlayCenter(full, stylePanelFocused.Padding(1, 3).Render(dlMsg), a.width, a.height)
    704 	}
    705 	if a.state == stateDone {
    706 		msg := styleSuccess.Render("✓ ") + a.statusOK + "\n\n" + styleStatusBar.Render("[esc] dismiss")
    707 		return overlayCenter(full, stylePanelFocused.Padding(1, 3).Render(msg), a.width, a.height)
    708 	}
    709 
    710 	return full
    711 }
    712 
    713 func (a *App) loginView() string {
    714 	var sb strings.Builder
    715 	sb.WriteString(styleTitle.Render(" WRDS") + styleStatusBar.Render("  Wharton Research Data Services") + "\n\n")
    716 	sb.WriteString(a.loginForm.View(a.width, a.loginErr))
    717 	return lipgloss.Place(a.width, a.height, lipgloss.Center, lipgloss.Center, sb.String())
    718 }
    719 
    720 func (a *App) footerView() string {
    721 	keys := "[tab] pane  [→/l] select  [←/h] back  [d] download  [b] databases  [/] filter  [q] quit"
    722 	footer := styleStatusBar.Render(keys)
    723 	if a.statusErr != "" {
    724 		errText := a.statusErr
    725 		maxLen := a.width - 12
    726 		if maxLen > 0 && len(errText) > maxLen {
    727 			errText = errText[:maxLen-1] + "…"
    728 		}
    729 		errBar := lipgloss.NewStyle().
    730 			Foreground(lipgloss.Color("#FFFFFF")).
    731 			Background(colorError).
    732 			Width(a.width).
    733 			Padding(0, 1).
    734 			Render("Error: " + errText)
    735 		footer = errBar + "\n" + footer
    736 	}
    737 	return footer
    738 }
    739 
    740 func (a *App) renderListPanel(l list.Model, title string, p pane, w, h, mr int) string {
    741 	l.SetSize(w-2, h-2)
    742 	content := l.View()
    743 	style := stylePanelBlurred
    744 	if a.focus == p {
    745 		style = stylePanelFocused
    746 	}
    747 	return style.Width(w - 2).Height(h).MarginRight(mr).Render(content)
    748 }
    749 
    750 func (a *App) renderPreviewPanel(w, h int) string {
    751 	var sb strings.Builder
    752 	label := "Preview"
    753 	if a.selectedSchema != "" && a.selectedTable != "" {
    754 		label = fmt.Sprintf("Preview: %s.%s", a.selectedSchema, a.selectedTable)
    755 	}
    756 	sb.WriteString(stylePanelHeader.Render(label) + "\n")
    757 
    758 	contentW := w - 4 // panel border + internal padding
    759 
    760 	if a.previewMeta != nil {
    761 		meta := a.previewMeta
    762 
    763 		// Stats line: "~245.3M rows · 1.2 GB"
    764 		var stats []string
    765 		if meta.RowCount > 0 {
    766 			stats = append(stats, "~"+formatCount(meta.RowCount)+" rows")
    767 		}
    768 		if meta.Size != "" {
    769 			stats = append(stats, meta.Size)
    770 		}
    771 		if len(stats) > 0 {
    772 			sb.WriteString(styleRowCount.Render(strings.Join(stats, " · ")) + "\n")
    773 		}
    774 		if meta.Comment != "" {
    775 			sb.WriteString(styleStatusBar.Render(meta.Comment) + "\n")
    776 		}
    777 
    778 		// Filter bar
    779 		if a.previewFiltering {
    780 			sb.WriteString(a.previewFilter.View() + "\n")
    781 		} else if a.previewFilter.Value() != "" {
    782 			sb.WriteString(styleStatusBar.Render("/ "+a.previewFilter.Value()) + "\n")
    783 		}
    784 
    785 		cols := a.filteredColumns()
    786 
    787 		if len(cols) > 0 {
    788 			// Calculate column widths from data.
    789 			nameW, typeW := len("Column"), len("Type")
    790 			for _, c := range cols {
    791 				if len(c.Name) > nameW {
    792 					nameW = len(c.Name)
    793 				}
    794 				if len(c.DataType) > typeW {
    795 					typeW = len(c.DataType)
    796 				}
    797 			}
    798 			if nameW > 22 {
    799 				nameW = 22
    800 			}
    801 			if typeW > 20 {
    802 				typeW = 20
    803 			}
    804 			descW := contentW - nameW - typeW - 4 // 2-char gaps
    805 			if descW < 8 {
    806 				descW = 8
    807 			}
    808 
    809 			// Column header
    810 			hdr := fmt.Sprintf("%-*s  %-*s  %-*s", nameW, "Column", typeW, "Type", descW, "Description")
    811 			sb.WriteString(styleCellHeader.Render(truncStr(hdr, contentW)) + "\n")
    812 			sb.WriteString(lipgloss.NewStyle().Foreground(colorMuted).Render(strings.Repeat("─", contentW)) + "\n")
    813 
    814 			// How many rows fit?
    815 			usedLines := lipgloss.Height(sb.String())
    816 			footerLines := 1
    817 			availRows := h - usedLines - footerLines - 2
    818 			if availRows < 1 {
    819 				availRows = 1
    820 			}
    821 
    822 			start := a.previewScroll
    823 			end := start + availRows
    824 			if end > len(cols) {
    825 				end = len(cols)
    826 			}
    827 
    828 			for i := start; i < end; i++ {
    829 				c := cols[i]
    830 				line := fmt.Sprintf("%-*s  %-*s  %s",
    831 					nameW, truncStr(c.Name, nameW),
    832 					typeW, truncStr(c.DataType, typeW),
    833 					truncStr(c.Description, descW))
    834 				style := styleCellNormal
    835 				if i%2 == 0 {
    836 					style = style.Foreground(lipgloss.Color("#D1D5DB"))
    837 				}
    838 				sb.WriteString(style.Render(line) + "\n")
    839 			}
    840 		}
    841 
    842 		// Column count footer
    843 		total := len(meta.Columns)
    844 		shown := len(cols)
    845 		countStr := fmt.Sprintf("%d columns", total)
    846 		if shown < total {
    847 			countStr = fmt.Sprintf("%d/%d columns", shown, total)
    848 		}
    849 		sb.WriteString(styleRowCount.Render(countStr))
    850 
    851 	} else if a.selectedTable != "" {
    852 		sb.WriteString(styleStatusBar.Render("Loading…"))
    853 	} else {
    854 		sb.WriteString(styleStatusBar.Render("Select a table to preview"))
    855 	}
    856 
    857 	style := stylePanelBlurred
    858 	if a.focus == panePreview {
    859 		style = stylePanelFocused
    860 	}
    861 	return style.Width(w - 2).Height(h).Render(sb.String())
    862 }
    863 
    864 func (a *App) panelWidths() (int, int, int) {
    865 	schema := 24
    866 	tbl := 30
    867 	margins := 2 // MarginRight(1) on schema + table panels
    868 	preview := a.width - schema - tbl - margins
    869 	if preview < 30 {
    870 		preview = 30
    871 	}
    872 	return schema, tbl, preview
    873 }
    874 
    875 func (a *App) resizePanels() {}
    876 
    877 // focusedListFiltering returns true if the currently focused list is in filter mode.
    878 func (a *App) focusedListFiltering() bool {
    879 	switch a.focus {
    880 	case paneSchema:
    881 		return a.schemaList.FilterState() == list.Filtering
    882 	case paneTable:
    883 		return a.tableList.FilterState() == list.Filtering
    884 	}
    885 	return false
    886 }
    887 
    888 // filteredColumns returns the columns matching the current filter text.
    889 func (a *App) filteredColumns() []db.ColumnMeta {
    890 	if a.previewMeta == nil {
    891 		return nil
    892 	}
    893 	filter := strings.ToLower(a.previewFilter.Value())
    894 	if filter == "" {
    895 		return a.previewMeta.Columns
    896 	}
    897 	var out []db.ColumnMeta
    898 	for _, col := range a.previewMeta.Columns {
    899 		if strings.Contains(strings.ToLower(col.Name), filter) ||
    900 			strings.Contains(strings.ToLower(col.Description), filter) {
    901 			out = append(out, col)
    902 		}
    903 	}
    904 	return out
    905 }
    906 
    907 // Err returns the last error message (login or status), if any.
    908 func (a *App) Err() string {
    909 	if a.loginErr != "" {
    910 		return a.loginErr
    911 	}
    912 	return a.statusErr
    913 }
    914 
    915 // -- helpers --
    916 
    917 func selectedItemTitle(l list.Model) string {
    918 	if sel := l.SelectedItem(); sel != nil {
    919 		return sel.(item).title
    920 	}
    921 	return ""
    922 }
    923 
    924 func truncStr(s string, max int) string {
    925 	r := []rune(s)
    926 	if len(r) <= max {
    927 		return s
    928 	}
    929 	if max <= 1 {
    930 		return "…"
    931 	}
    932 	return string(r[:max-1]) + "…"
    933 }
    934 
    935 func formatCount(n int64) string {
    936 	if n >= 1_000_000_000 {
    937 		return fmt.Sprintf("%.1fB", float64(n)/1e9)
    938 	}
    939 	if n >= 1_000_000 {
    940 		return fmt.Sprintf("%.1fM", float64(n)/1e6)
    941 	}
    942 	if n >= 1_000 {
    943 		return fmt.Sprintf("%.1fK", float64(n)/1e3)
    944 	}
    945 	return fmt.Sprintf("%d", n)
    946 }
    947 
    948 // overlayCenter places overlay on top of base, centered.
    949 func overlayCenter(base, overlay string, w, h int) string {
    950 	_ = w
    951 	_ = h
    952 	// Simple approach: render overlay below header.
    953 	lines := strings.Split(base, "\n")
    954 	overlayLines := strings.Split(overlay, "\n")
    955 
    956 	startRow := (len(lines) - len(overlayLines)) / 2
    957 	if startRow < 0 {
    958 		startRow = 0
    959 	}
    960 
    961 	for i, ol := range overlayLines {
    962 		row := startRow + i
    963 		if row < len(lines) {
    964 			lineRunes := []rune(lines[row])
    965 			olRunes := []rune(ol)
    966 			startCol := (w - lipgloss.Width(ol)) / 2
    967 			if startCol < 0 {
    968 				startCol = 0
    969 			}
    970 			_ = lineRunes
    971 			_ = olRunes
    972 			_ = startCol
    973 			lines[row] = ol
    974 		}
    975 	}
    976 	return strings.Join(lines, "\n")
    977 }