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 }