use cobra/fang

This commit is contained in:
2025-07-12 22:40:08 -07:00
parent f2358a1ab5
commit 686cb97eb3
15 changed files with 1427 additions and 1736 deletions

605
internal/app/app.go Normal file
View File

@@ -0,0 +1,605 @@
package app
import (
"database/sql"
"fmt"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
_ "modernc.org/sqlite" // Import SQLite driver
)
const (
PageSize = 20
)
// Custom message types
type (
SwitchToTableListMsg struct{}
SwitchToTableDataMsg struct{ TableIndex int }
SwitchToRowDetailMsg struct{ RowIndex int }
SwitchToRowDetailFromQueryMsg struct{ RowIndex int }
SwitchToEditCellMsg struct{ RowIndex, ColIndex int }
SwitchToQueryMsg struct{}
ReturnToQueryMsg struct{} // Return to query mode from row detail
RefreshDataMsg struct{}
UpdateCellMsg struct {
RowIndex, ColIndex int
Value string
}
ExecuteQueryMsg struct{ Query string }
)
// Main application model
type Model struct {
db *sql.DB
currentView tea.Model
width int
height int
err error
}
// Shared data that all models need access to
type SharedData struct {
DB *sql.DB
Tables []string
FilteredTables []string
TableData [][]string
FilteredData [][]string
Columns []string
PrimaryKeys []string
SelectedTable int
TotalRows int
CurrentPage int
Width int
Height int
// Query result context
IsQueryResult bool
QueryTableName string // For simple queries, store the source table
}
func NewSharedData(db *sql.DB) *SharedData {
return &SharedData{
DB: db,
FilteredTables: []string{},
FilteredData: [][]string{},
Width: 80,
Height: 24,
}
}
func (s *SharedData) LoadTables() error {
query := `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name`
rows, err := s.DB.Query(query)
if err != nil {
return err
}
defer rows.Close()
s.Tables = []string{}
for rows.Next() {
var name string
if err := rows.Scan(&name); err != nil {
return err
}
s.Tables = append(s.Tables, name)
}
s.FilteredTables = make([]string, len(s.Tables))
copy(s.FilteredTables, s.Tables)
return nil
}
func (s *SharedData) LoadTableData() error {
if s.SelectedTable >= len(s.FilteredTables) {
return fmt.Errorf("invalid table selection")
}
tableName := s.FilteredTables[s.SelectedTable]
// Get column info and primary keys
rows, err := s.DB.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
if err != nil {
return err
}
defer rows.Close()
s.Columns = []string{}
s.PrimaryKeys = []string{}
for rows.Next() {
var cid int
var name, dataType string
var notNull, pk int
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &dataType, &notNull, &defaultValue, &pk); err != nil {
return err
}
s.Columns = append(s.Columns, name)
if pk == 1 {
s.PrimaryKeys = append(s.PrimaryKeys, name)
}
}
// Get total row count
countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName)
err = s.DB.QueryRow(countQuery).Scan(&s.TotalRows)
if err != nil {
return err
}
// Get paginated data
offset := s.CurrentPage * PageSize
dataQuery := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableName, PageSize, offset)
rows, err = s.DB.Query(dataQuery)
if err != nil {
return err
}
defer rows.Close()
s.TableData = [][]string{}
for rows.Next() {
values := make([]any, len(s.Columns))
valuePtrs := make([]any, len(s.Columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
return err
}
row := make([]string, len(s.Columns))
for i, val := range values {
if val == nil {
row[i] = "NULL"
} else {
row[i] = fmt.Sprintf("%v", val)
}
}
s.TableData = append(s.TableData, row)
}
s.FilteredData = make([][]string, len(s.TableData))
copy(s.FilteredData, s.TableData)
// Reset query result context since this is regular table data
s.IsQueryResult = false
s.QueryTableName = ""
return nil
}
func (s *SharedData) UpdateCell(rowIndex, colIndex int, newValue string) error {
if rowIndex >= len(s.FilteredData) || colIndex >= len(s.Columns) {
return fmt.Errorf("invalid row or column index")
}
var tableName string
var err error
if s.IsQueryResult {
// For query results, try to determine the source table
if s.QueryTableName != "" {
tableName = s.QueryTableName
} else {
// Try to infer table from column names and data
tableName, err = s.inferTableFromQueryResult(rowIndex, colIndex)
if err != nil {
return fmt.Errorf("cannot determine source table for query result: %v", err)
}
}
} else {
// For regular table data
tableName = s.FilteredTables[s.SelectedTable]
}
columnName := s.Columns[colIndex]
// Get table info for the target table to find primary keys
tableColumns, tablePrimaryKeys, err := s.getTableInfo(tableName)
if err != nil {
return fmt.Errorf("failed to get table info for %s: %v", tableName, err)
}
// Build WHERE clause using primary keys or all columns if no primary key
var whereClause strings.Builder
var args []any
if len(tablePrimaryKeys) > 0 {
// Use primary keys for WHERE clause
for i, pkCol := range tablePrimaryKeys {
if i > 0 {
whereClause.WriteString(" AND ")
}
// Find the value for this primary key in our data
pkValue, err := s.findColumnValue(rowIndex, pkCol, tableColumns)
if err != nil {
return fmt.Errorf("failed to find primary key value for %s: %v", pkCol, err)
}
whereClause.WriteString(fmt.Sprintf("%s = ?", pkCol))
args = append(args, pkValue)
}
} else {
// Use all columns for WHERE clause (less reliable but works)
for i, col := range tableColumns {
if i > 0 {
whereClause.WriteString(" AND ")
}
colValue, err := s.findColumnValue(rowIndex, col, tableColumns)
if err != nil {
return fmt.Errorf("failed to find column value for %s: %v", col, err)
}
whereClause.WriteString(fmt.Sprintf("%s = ?", col))
args = append(args, colValue)
}
}
// Execute UPDATE
updateQuery := fmt.Sprintf("UPDATE %s SET %s = ? WHERE %s", tableName, columnName, whereClause.String())
args = append([]any{newValue}, args...)
_, err = s.DB.Exec(updateQuery, args...)
if err != nil {
return err
}
// Update local data
s.FilteredData[rowIndex][colIndex] = newValue
// Also update the original data if it exists
for i, row := range s.TableData {
if len(row) > colIndex {
match := true
for j, cell := range row {
if j < len(s.FilteredData[rowIndex]) && cell != s.FilteredData[rowIndex][j] && j != colIndex {
match = false
break
}
}
if match {
s.TableData[i][colIndex] = newValue
break
}
}
}
return nil
}
// Helper function to get table info
func (s *SharedData) getTableInfo(tableName string) ([]string, []string, error) {
rows, err := s.DB.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName))
if err != nil {
return nil, nil, err
}
defer rows.Close()
var columns []string
var primaryKeys []string
for rows.Next() {
var cid int
var name, dataType string
var notNull, pk int
var defaultValue sql.NullString
if err := rows.Scan(&cid, &name, &dataType, &notNull, &defaultValue, &pk); err != nil {
return nil, nil, err
}
columns = append(columns, name)
if pk == 1 {
primaryKeys = append(primaryKeys, name)
}
}
return columns, primaryKeys, nil
}
// Helper function to find a column value in the current row
func (s *SharedData) findColumnValue(rowIndex int, columnName string, _ []string) (string, error) {
// First try to find it in our current columns (for query results)
for i, col := range s.Columns {
if col == columnName && i < len(s.FilteredData[rowIndex]) {
return s.FilteredData[rowIndex][i], nil
}
}
// If not found, this might be a column that's not in the query result
// We'll need to query the database to get the current value
if s.IsQueryResult && len(s.PrimaryKeys) > 0 {
// Build a query to get the missing column value using available primary keys
var whereClause strings.Builder
var args []any
for i, pkCol := range s.PrimaryKeys {
if i > 0 {
whereClause.WriteString(" AND ")
}
// Find primary key value in our data
pkIndex := -1
for j, col := range s.Columns {
if col == pkCol {
pkIndex = j
break
}
}
if pkIndex >= 0 {
whereClause.WriteString(fmt.Sprintf("%s = ?", pkCol))
args = append(args, s.FilteredData[rowIndex][pkIndex])
}
}
if whereClause.Len() > 0 {
tableName := s.QueryTableName
if tableName == "" {
// Try to infer table name
tableName, _ = s.inferTableFromQueryResult(rowIndex, 0)
}
query := fmt.Sprintf("SELECT %s FROM %s WHERE %s", columnName, tableName, whereClause.String())
var value string
err := s.DB.QueryRow(query, args...).Scan(&value)
if err != nil {
return "", err
}
return value, nil
}
}
return "", fmt.Errorf("column %s not found in current data", columnName)
}
// Helper function to try to infer the source table from query results
func (s *SharedData) inferTableFromQueryResult(_, _ int) (string, error) {
// This is a simple heuristic - try to find a table that has all our columns
for _, tableName := range s.Tables {
tableColumns, _, err := s.getTableInfo(tableName)
if err != nil {
continue
}
// Check if this table has all our columns
hasAllColumns := true
for _, queryCol := range s.Columns {
found := slices.Contains(tableColumns, queryCol)
if !found {
hasAllColumns = false
break
}
}
if hasAllColumns {
// Cache this for future use
s.QueryTableName = tableName
return tableName, nil
}
}
return "", fmt.Errorf("could not infer source table from query result")
}
// Styles
var (
TitleStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#7D56F4")).
Padding(0, 1)
SelectedStyle = lipgloss.NewStyle().
Bold(true).
Foreground(lipgloss.Color("#FAFAFA")).
Background(lipgloss.Color("#F25D94"))
NormalStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FAFAFA"))
ErrorStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#FF0000")).
Bold(true)
HelpStyle = lipgloss.NewStyle().
Foreground(lipgloss.Color("#626262"))
)
// Utility functions
func TruncateString(s string, maxLen int) string {
if len(s) <= maxLen {
return s
}
return s[:maxLen-3] + "..."
}
func WrapText(text string, width int) []string {
if width <= 0 {
return []string{text}
}
var lines []string
words := strings.Fields(text)
if len(words) == 0 {
return []string{text}
}
currentLine := ""
for _, word := range words {
if len(currentLine)+len(word)+1 > width {
if currentLine != "" {
lines = append(lines, currentLine)
currentLine = word
} else {
for len(word) > width {
lines = append(lines, word[:width])
word = word[width:]
}
currentLine = word
}
} else {
if currentLine != "" {
currentLine += " " + word
} else {
currentLine = word
}
}
}
if currentLine != "" {
lines = append(lines, currentLine)
}
return lines
}
func Min(a, b int) int {
if a < b {
return a
}
return b
}
func Max(a, b int) int {
if a > b {
return a
}
return b
}
func InitialModel(db *sql.DB) *Model {
shared := NewSharedData(db)
if err := shared.LoadTables(); err != nil {
return &Model{err: err}
}
return &Model{
db: db,
currentView: NewTableListModel(shared),
width: 80,
height: 24,
}
}
func (m *Model) Init() tea.Cmd {
return m.currentView.Init()
}
func (m *Model) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.WindowSizeMsg:
m.width = msg.Width
m.height = msg.Height
// Update current view with new dimensions
if tableList, ok := m.currentView.(*TableListModel); ok {
tableList.Shared.Width = m.width
tableList.Shared.Height = m.height
}
// Add similar updates for other model types as needed
case tea.KeyMsg:
if msg.String() == "ctrl+c" {
return m, tea.Quit
}
case SwitchToTableListMsg:
m.currentView = NewTableListModel(m.getSharedData())
return m, nil
case SwitchToTableDataMsg:
shared := m.getSharedData()
shared.SelectedTable = msg.TableIndex
if err := shared.LoadTableData(); err != nil {
m.err = err
return m, nil
}
m.currentView = NewTableDataModel(shared)
return m, nil
case SwitchToRowDetailMsg:
m.currentView = NewRowDetailModel(m.getSharedData(), msg.RowIndex)
return m, nil
case SwitchToRowDetailFromQueryMsg:
rowDetail := NewRowDetailModel(m.getSharedData(), msg.RowIndex)
rowDetail.FromQuery = true
m.currentView = rowDetail
return m, nil
case SwitchToEditCellMsg:
m.currentView = NewEditCellModel(m.getSharedData(), msg.RowIndex, msg.ColIndex)
return m, nil
case SwitchToQueryMsg:
m.currentView = NewQueryModel(m.getSharedData())
return m, nil
case ReturnToQueryMsg:
// Return to query mode, preserving the query state if possible
if queryView, ok := m.currentView.(*QueryModel); ok {
// If we're already in query mode, just switch focus back to results
queryView.FocusOnInput = false
} else {
// Create new query model
m.currentView = NewQueryModel(m.getSharedData())
}
return m, nil
case RefreshDataMsg:
shared := m.getSharedData()
if err := shared.LoadTableData(); err != nil {
m.err = err
}
return m, nil
case UpdateCellMsg:
shared := m.getSharedData()
if err := shared.UpdateCell(msg.RowIndex, msg.ColIndex, msg.Value); err != nil {
m.err = err
}
return m, func() tea.Msg { return SwitchToRowDetailMsg{msg.RowIndex} }
}
if m.err != nil {
return m, nil
}
var cmd tea.Cmd
m.currentView, cmd = m.currentView.Update(msg)
return m, cmd
}
func (m *Model) View() string {
if m.err != nil {
return ErrorStyle.Render(fmt.Sprintf("Error: %v\n\nPress 'ctrl+c' to quit", m.err))
}
return m.currentView.View()
}
func (m *Model) Err() error {
return m.err
}
func (m *Model) getSharedData() *SharedData {
// Extract shared data from current view
switch v := m.currentView.(type) {
case *TableListModel:
return v.Shared
case *TableDataModel:
return v.Shared
case *RowDetailModel:
return v.Shared
case *EditCellModel:
return v.Shared
case *QueryModel:
return v.Shared
default:
// Fallback - create new shared data
return NewSharedData(m.db)
}
}

91
internal/app/edit_cell.go Normal file
View File

@@ -0,0 +1,91 @@
package app
import (
"fmt"
tea "github.com/charmbracelet/bubbletea"
)
// Edit Cell Model
type EditCellModel struct {
Shared *SharedData
rowIndex int
colIndex int
value string
cursor int
}
func NewEditCellModel(shared *SharedData, rowIndex, colIndex int) *EditCellModel {
value := ""
if rowIndex < len(shared.FilteredData) && colIndex < len(shared.FilteredData[rowIndex]) {
value = shared.FilteredData[rowIndex][colIndex]
}
return &EditCellModel{
Shared: shared,
rowIndex: rowIndex,
colIndex: colIndex,
value: value,
cursor: len(value),
}
}
func (m *EditCellModel) Init() tea.Cmd {
return nil
}
func (m *EditCellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "esc":
return m, func() tea.Msg { return SwitchToRowDetailMsg{RowIndex: m.rowIndex} }
case "enter":
return m, func() tea.Msg {
return UpdateCellMsg{
RowIndex: m.rowIndex,
ColIndex: m.colIndex,
Value: m.value,
}
}
case "backspace":
if m.cursor > 0 {
m.value = m.value[:m.cursor-1] + m.value[m.cursor:]
m.cursor--
}
case "left":
if m.cursor > 0 {
m.cursor--
}
case "right":
if m.cursor < len(m.value) {
m.cursor++
}
default:
if len(msg.String()) == 1 {
m.value = m.value[:m.cursor] + msg.String() + m.value[m.cursor:]
m.cursor++
}
}
}
return m, nil
}
func (m *EditCellModel) View() string {
columnName := ""
if m.colIndex < len(m.Shared.Columns) {
columnName = m.Shared.Columns[m.colIndex]
}
content := TitleStyle.Render(fmt.Sprintf("Edit Cell: %s", columnName)) + "\n\n"
content += fmt.Sprintf("Value: %s\n", m.value)
content += fmt.Sprintf("Cursor: %d\n\n", m.cursor)
content += HelpStyle.Render("enter: save • esc: cancel")
return content
}

231
internal/app/query.go Normal file
View File

@@ -0,0 +1,231 @@
package app
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Query Model
type QueryModel struct {
Shared *SharedData
query string
cursor int
FocusOnInput bool
selectedRow int
results [][]string
columns []string
err error
}
func NewQueryModel(shared *SharedData) *QueryModel {
return &QueryModel{
Shared: shared,
FocusOnInput: true,
selectedRow: 0,
}
}
func (m *QueryModel) Init() tea.Cmd {
return nil
}
func (m *QueryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.FocusOnInput {
return m.handleQueryInput(msg)
}
return m.handleResultsNavigation(msg)
}
return m, nil
}
func (m *QueryModel) handleQueryInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
return m, func() tea.Msg { return SwitchToTableListMsg{} }
case "enter":
if strings.TrimSpace(m.query) != "" {
return m, m.executeQuery()
}
case "backspace":
if m.cursor > 0 {
m.query = m.query[:m.cursor-1] + m.query[m.cursor:]
m.cursor--
}
case "left":
if m.cursor > 0 {
m.cursor--
}
case "right":
if m.cursor < len(m.query) {
m.cursor++
}
default:
if len(msg.String()) == 1 {
m.query = m.query[:m.cursor] + msg.String() + m.query[m.cursor:]
m.cursor++
}
}
return m, nil
}
func (m *QueryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "q":
return m, func() tea.Msg { return SwitchToTableListMsg{} }
case "i":
m.FocusOnInput = true
return m, nil
case "enter":
if len(m.results) > 0 {
return m, func() tea.Msg {
return SwitchToRowDetailFromQueryMsg{RowIndex: m.selectedRow}
}
}
case "up", "k":
if m.selectedRow > 0 {
m.selectedRow--
}
case "down", "j":
if m.selectedRow < len(m.results)-1 {
m.selectedRow++
}
}
return m, nil
}
func (m *QueryModel) executeQuery() tea.Cmd {
return func() tea.Msg {
rows, err := m.Shared.DB.Query(m.query)
if err != nil {
m.err = err
return nil
}
defer rows.Close()
// Get column names
columns, err := rows.Columns()
if err != nil {
m.err = err
return nil
}
m.columns = columns
// Get results
m.results = [][]string{}
for rows.Next() {
values := make([]any, len(columns))
valuePtrs := make([]any, len(columns))
for i := range values {
valuePtrs[i] = &values[i]
}
if err := rows.Scan(valuePtrs...); err != nil {
m.err = err
return nil
}
row := make([]string, len(columns))
for i, val := range values {
if val == nil {
row[i] = "NULL"
} else {
row[i] = fmt.Sprintf("%v", val)
}
}
m.results = append(m.results, row)
}
// Update shared data for row detail view
m.Shared.FilteredData = m.results
m.Shared.Columns = m.columns
m.Shared.IsQueryResult = true
m.FocusOnInput = false
m.selectedRow = 0
m.err = nil
return nil
}
}
func (m *QueryModel) View() string {
var content strings.Builder
content.WriteString(TitleStyle.Render("SQL Query"))
content.WriteString("\n\n")
// Query input
content.WriteString("Query: ")
if m.FocusOnInput {
content.WriteString(m.query + "_")
} else {
content.WriteString(m.query)
}
content.WriteString("\n\n")
// Error display
if m.err != nil {
content.WriteString(ErrorStyle.Render(fmt.Sprintf("Error: %v", m.err)))
content.WriteString("\n\n")
}
// Results
if len(m.results) > 0 {
// Column headers
headerRow := ""
for i, col := range m.columns {
if i > 0 {
headerRow += " | "
}
headerRow += TruncateString(col, 15)
}
content.WriteString(TitleStyle.Render(headerRow))
content.WriteString("\n")
// Data rows
visibleCount := Max(1, m.Shared.Height-10)
endIdx := Min(len(m.results), visibleCount)
for i := 0; i < endIdx; i++ {
row := m.results[i]
rowStr := ""
for j, cell := range row {
if j > 0 {
rowStr += " | "
}
rowStr += TruncateString(cell, 15)
}
if i == m.selectedRow && !m.FocusOnInput {
content.WriteString(SelectedStyle.Render("> " + rowStr))
} else {
content.WriteString(NormalStyle.Render(" " + rowStr))
}
content.WriteString("\n")
}
content.WriteString(fmt.Sprintf("\n%d rows returned\n", len(m.results)))
}
content.WriteString("\n")
if m.FocusOnInput {
content.WriteString(HelpStyle.Render("enter: execute • esc: back"))
} else {
content.WriteString(HelpStyle.Render("↑/↓: navigate • enter: details • i: edit query • q: back"))
}
return content.String()
}

View File

@@ -0,0 +1,71 @@
package app
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Row Detail Model
type RowDetailModel struct {
Shared *SharedData
rowIndex int
FromQuery bool
}
func NewRowDetailModel(shared *SharedData, rowIndex int) *RowDetailModel {
return &RowDetailModel{
Shared: shared,
rowIndex: rowIndex,
}
}
func (m *RowDetailModel) Init() tea.Cmd {
return nil
}
func (m *RowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
switch msg.String() {
case "q", "esc":
if m.FromQuery {
return m, func() tea.Msg { return ReturnToQueryMsg{} }
}
return m, func() tea.Msg { return SwitchToTableDataMsg{TableIndex: m.Shared.SelectedTable} }
case "e":
if len(m.Shared.FilteredData) > m.rowIndex && len(m.Shared.Columns) > 0 {
return m, func() tea.Msg {
return SwitchToEditCellMsg{RowIndex: m.rowIndex, ColIndex: 0}
}
}
}
}
return m, nil
}
func (m *RowDetailModel) View() string {
var content strings.Builder
content.WriteString(TitleStyle.Render("Row Details"))
content.WriteString("\n\n")
if m.rowIndex >= len(m.Shared.FilteredData) {
content.WriteString("Invalid row index")
return content.String()
}
row := m.Shared.FilteredData[m.rowIndex]
for i, col := range m.Shared.Columns {
if i < len(row) {
content.WriteString(fmt.Sprintf("%s: %s\n", col, row[i]))
}
}
content.WriteString("\n")
content.WriteString(HelpStyle.Render("e: edit • q: back"))
return content.String()
}

205
internal/app/table_data.go Normal file
View File

@@ -0,0 +1,205 @@
package app
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Table Data Model
type TableDataModel struct {
Shared *SharedData
selectedRow int
searchInput string
searching bool
}
func NewTableDataModel(shared *SharedData) *TableDataModel {
return &TableDataModel{
Shared: shared,
selectedRow: 0,
}
}
func (m *TableDataModel) Init() tea.Cmd {
return nil
}
func (m *TableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.searching {
return m.handleSearchInput(msg)
}
return m.handleNavigation(msg)
}
return m, nil
}
func (m *TableDataModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "enter":
m.searching = false
m.filterData()
case "backspace":
if len(m.searchInput) > 0 {
m.searchInput = m.searchInput[:len(m.searchInput)-1]
m.filterData()
}
default:
if len(msg.String()) == 1 {
m.searchInput += msg.String()
m.filterData()
}
}
return m, nil
}
func (m *TableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "q", "esc":
return m, func() tea.Msg { return SwitchToTableListMsg{} }
case "enter":
if len(m.Shared.FilteredData) > 0 {
return m, func() tea.Msg {
return SwitchToRowDetailMsg{RowIndex: m.selectedRow}
}
}
case "/":
m.searching = true
m.searchInput = ""
return m, nil
case "s":
return m, func() tea.Msg { return SwitchToQueryMsg{} }
case "r":
if err := m.Shared.LoadTableData(); err == nil {
m.filterData()
}
case "up", "k":
if m.selectedRow > 0 {
m.selectedRow--
}
case "down", "j":
if m.selectedRow < len(m.Shared.FilteredData)-1 {
m.selectedRow++
}
case "left", "h":
if m.Shared.CurrentPage > 0 {
m.Shared.CurrentPage--
m.Shared.LoadTableData()
m.selectedRow = 0
}
case "right", "l":
maxPage := (m.Shared.TotalRows - 1) / PageSize
if m.Shared.CurrentPage < maxPage {
m.Shared.CurrentPage++
m.Shared.LoadTableData()
m.selectedRow = 0
}
}
return m, nil
}
func (m *TableDataModel) filterData() {
if m.searchInput == "" {
m.Shared.FilteredData = make([][]string, len(m.Shared.TableData))
copy(m.Shared.FilteredData, m.Shared.TableData)
} else {
m.Shared.FilteredData = [][]string{}
searchLower := strings.ToLower(m.searchInput)
for _, row := range m.Shared.TableData {
for _, cell := range row {
if strings.Contains(strings.ToLower(cell), searchLower) {
m.Shared.FilteredData = append(m.Shared.FilteredData, row)
break
}
}
}
}
if m.selectedRow >= len(m.Shared.FilteredData) {
m.selectedRow = 0
}
}
func (m *TableDataModel) View() string {
var content strings.Builder
tableName := ""
if m.Shared.SelectedTable < len(m.Shared.FilteredTables) {
tableName = m.Shared.FilteredTables[m.Shared.SelectedTable]
}
content.WriteString(TitleStyle.Render(fmt.Sprintf("Table: %s", tableName)))
content.WriteString("\n")
if m.searching {
content.WriteString("\nSearch: " + m.searchInput + "_")
content.WriteString("\n")
} else if m.searchInput != "" {
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d rows)",
m.searchInput, len(m.Shared.FilteredData), len(m.Shared.TableData)))
content.WriteString("\n")
}
// Show pagination info
totalPages := (m.Shared.TotalRows-1)/PageSize + 1
content.WriteString(fmt.Sprintf("Page %d/%d (%d total rows)\n\n",
m.Shared.CurrentPage+1, totalPages, m.Shared.TotalRows))
if len(m.Shared.FilteredData) == 0 {
content.WriteString("No data found")
} else {
// Show column headers
headerRow := ""
for i, col := range m.Shared.Columns {
if i > 0 {
headerRow += " | "
}
headerRow += TruncateString(col, 15)
}
content.WriteString(TitleStyle.Render(headerRow))
content.WriteString("\n")
// Show data rows
visibleCount := Max(1, m.Shared.Height-10)
startIdx := 0
endIdx := Min(len(m.Shared.FilteredData), visibleCount)
for i := startIdx; i < endIdx; i++ {
row := m.Shared.FilteredData[i]
rowStr := ""
for j, cell := range row {
if j > 0 {
rowStr += " | "
}
rowStr += TruncateString(cell, 15)
}
if i == m.selectedRow {
content.WriteString(SelectedStyle.Render("> " + rowStr))
} else {
content.WriteString(NormalStyle.Render(" " + rowStr))
}
content.WriteString("\n")
}
}
content.WriteString("\n")
if m.searching {
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
} else {
content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: details • s: SQL • r: refresh • q: back"))
}
return content.String()
}

198
internal/app/table_list.go Normal file
View File

@@ -0,0 +1,198 @@
package app
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Table List Model
type TableListModel struct {
Shared *SharedData
searchInput string
searching bool
selectedTable int
currentPage int
}
func NewTableListModel(shared *SharedData) *TableListModel {
return &TableListModel{
Shared: shared,
selectedTable: 0,
currentPage: 0,
}
}
func (m *TableListModel) Init() tea.Cmd {
return nil
}
func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.searching {
return m.handleSearchInput(msg)
}
return m.handleNavigation(msg)
}
return m, nil
}
func (m *TableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "enter":
m.searching = false
m.filterTables()
case "backspace":
if len(m.searchInput) > 0 {
m.searchInput = m.searchInput[:len(m.searchInput)-1]
m.filterTables()
}
default:
if len(msg.String()) == 1 {
m.searchInput += msg.String()
m.filterTables()
}
}
return m, nil
}
func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "/":
m.searching = true
m.searchInput = ""
return m, nil
case "enter":
if len(m.Shared.FilteredTables) > 0 {
return m, func() tea.Msg {
return SwitchToTableDataMsg{TableIndex: m.selectedTable}
}
}
case "s":
return m, func() tea.Msg { return SwitchToQueryMsg{} }
case "r":
if err := m.Shared.LoadTables(); err == nil {
m.filterTables()
}
case "up", "k":
if m.selectedTable > 0 {
m.selectedTable--
m.adjustPage()
}
case "down", "j":
if m.selectedTable < len(m.Shared.FilteredTables)-1 {
m.selectedTable++
m.adjustPage()
}
case "left", "h":
if m.currentPage > 0 {
m.currentPage--
m.selectedTable = m.currentPage * m.getVisibleCount()
}
case "right", "l":
maxPage := (len(m.Shared.FilteredTables) - 1) / m.getVisibleCount()
if m.currentPage < maxPage {
m.currentPage++
m.selectedTable = m.currentPage * m.getVisibleCount()
if m.selectedTable >= len(m.Shared.FilteredTables) {
m.selectedTable = len(m.Shared.FilteredTables) - 1
}
}
}
return m, nil
}
func (m *TableListModel) filterTables() {
if m.searchInput == "" {
m.Shared.FilteredTables = make([]string, len(m.Shared.Tables))
copy(m.Shared.FilteredTables, m.Shared.Tables)
} else {
m.Shared.FilteredTables = []string{}
searchLower := strings.ToLower(m.searchInput)
for _, table := range m.Shared.Tables {
if strings.Contains(strings.ToLower(table), searchLower) {
m.Shared.FilteredTables = append(m.Shared.FilteredTables, table)
}
}
}
if m.selectedTable >= len(m.Shared.FilteredTables) {
m.selectedTable = 0
m.currentPage = 0
}
}
func (m *TableListModel) getVisibleCount() int {
reservedLines := 8
if m.searching {
reservedLines += 2
}
return Max(1, m.Shared.Height-reservedLines)
}
func (m *TableListModel) adjustPage() {
visibleCount := m.getVisibleCount()
m.currentPage = m.selectedTable / visibleCount
}
func (m *TableListModel) View() string {
var content strings.Builder
content.WriteString(TitleStyle.Render("SQLite TUI - Tables"))
content.WriteString("\n")
if m.searching {
content.WriteString("\nSearch: " + m.searchInput + "_")
content.WriteString("\n")
} else if m.searchInput != "" {
content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d tables)",
m.searchInput, len(m.Shared.FilteredTables), len(m.Shared.Tables)))
content.WriteString("\n")
}
content.WriteString("\n")
if len(m.Shared.FilteredTables) == 0 {
if m.searchInput != "" {
content.WriteString("No tables match your search")
} else {
content.WriteString("No tables found in database")
}
} else {
visibleCount := m.getVisibleCount()
startIdx := m.currentPage * visibleCount
endIdx := Min(startIdx+visibleCount, len(m.Shared.FilteredTables))
for i := startIdx; i < endIdx; i++ {
table := m.Shared.FilteredTables[i]
if i == m.selectedTable {
content.WriteString(SelectedStyle.Render(fmt.Sprintf("> %s", table)))
} else {
content.WriteString(NormalStyle.Render(fmt.Sprintf(" %s", table)))
}
content.WriteString("\n")
}
if len(m.Shared.FilteredTables) > visibleCount {
totalPages := (len(m.Shared.FilteredTables)-1)/visibleCount + 1
content.WriteString(fmt.Sprintf("\nPage %d/%d", m.currentPage+1, totalPages))
}
}
content.WriteString("\n")
if m.searching {
content.WriteString(HelpStyle.Render("Type to search • enter/esc: finish search"))
} else {
content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: view • s: SQL • r: refresh • ctrl+c: quit"))
}
return content.String()
}