mirror of
https://github.com/taigrr/teaqlite.git
synced 2026-04-02 04:59:03 -07:00
460 lines
9.7 KiB
Go
460 lines
9.7 KiB
Go
package main
|
|
|
|
import (
|
|
"database/sql"
|
|
"fmt"
|
|
"log"
|
|
"os"
|
|
"strings"
|
|
|
|
tea "github.com/charmbracelet/bubbletea"
|
|
"github.com/charmbracelet/lipgloss"
|
|
_ "github.com/mattn/go-sqlite3"
|
|
)
|
|
|
|
const (
|
|
pageSize = 20
|
|
)
|
|
|
|
// Custom message types
|
|
type (
|
|
switchToTableListMsg struct{}
|
|
switchToTableDataMsg struct{ tableIndex int }
|
|
switchToRowDetailMsg struct{ rowIndex int }
|
|
switchToEditCellMsg struct{ rowIndex, colIndex int }
|
|
switchToQueryMsg struct{}
|
|
refreshDataMsg struct{}
|
|
updateCellMsg struct{ rowIndex, colIndex int; value string }
|
|
executeQueryMsg struct{ query string }
|
|
)
|
|
|
|
// Common interface for all models
|
|
type subModel interface {
|
|
Update(tea.Msg) (subModel, tea.Cmd)
|
|
View() string
|
|
Init() tea.Cmd
|
|
}
|
|
|
|
// Main application model
|
|
type model struct {
|
|
db *sql.DB
|
|
currentView subModel
|
|
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
|
|
}
|
|
|
|
func newSharedData(db *sql.DB) *sharedData {
|
|
return &sharedData{
|
|
db: db,
|
|
filteredTables: []string{},
|
|
filteredData: [][]string{},
|
|
width: 80,
|
|
height: 24,
|
|
}
|
|
}
|
|
|
|
func (s *sharedData) loadTables() error {
|
|
rows, err := s.db.Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name")
|
|
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, ¬Null, &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)
|
|
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")
|
|
}
|
|
|
|
tableName := s.filteredTables[s.selectedTable]
|
|
columnName := s.columns[colIndex]
|
|
|
|
// Build WHERE clause using primary keys or all columns if no primary key
|
|
var whereClause strings.Builder
|
|
var args []any
|
|
|
|
if len(s.primaryKeys) > 0 {
|
|
for i, pkCol := range s.primaryKeys {
|
|
if i > 0 {
|
|
whereClause.WriteString(" AND ")
|
|
}
|
|
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])
|
|
}
|
|
}
|
|
} else {
|
|
for i, col := range s.columns {
|
|
if i > 0 {
|
|
whereClause.WriteString(" AND ")
|
|
}
|
|
whereClause.WriteString(fmt.Sprintf("%s = ?", col))
|
|
args = append(args, s.filteredData[rowIndex][i])
|
|
}
|
|
}
|
|
|
|
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
|
|
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
|
|
}
|
|
|
|
// 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(dbPath string) model {
|
|
db, err := sql.Open("sqlite3", dbPath)
|
|
if err != nil {
|
|
return model{err: err}
|
|
}
|
|
|
|
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" || msg.String() == "q" {
|
|
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 switchToEditCellMsg:
|
|
m.currentView = newEditCellModel(m.getSharedData(), msg.rowIndex, msg.colIndex)
|
|
return m, nil
|
|
|
|
case switchToQueryMsg:
|
|
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 'q' to quit", m.err))
|
|
}
|
|
return m.currentView.View()
|
|
}
|
|
|
|
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)
|
|
}
|
|
}
|
|
|
|
func main() {
|
|
if len(os.Args) < 2 {
|
|
fmt.Println("Usage: go-sqlite-tui <database.db>")
|
|
os.Exit(1)
|
|
}
|
|
|
|
dbPath := os.Args[1]
|
|
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
|
fmt.Printf("Database file '%s' does not exist\n", dbPath)
|
|
os.Exit(1)
|
|
}
|
|
|
|
m := initialModel(dbPath)
|
|
if m.err != nil {
|
|
log.Fatal(m.err)
|
|
}
|
|
|
|
p := tea.NewProgram(m, tea.WithAltScreen())
|
|
if _, err := p.Run(); err != nil {
|
|
log.Fatal(err)
|
|
}
|
|
} |