From c61ec75f4b74308dad3638d043117ce19ca76c3c Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Fri, 11 Jul 2025 22:28:29 -0700 Subject: [PATCH] initial project --- .gitignore | 3 + README.md | 66 ++++++++ go.mod | 27 +++ go.sum | 39 +++++ main.go | 486 +++++++++++++++++++++++++++++++++++++++++++++++++++++ 5 files changed, 621 insertions(+) create mode 100644 .gitignore create mode 100644 README.md create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..5780e84 --- /dev/null +++ b/.gitignore @@ -0,0 +1,3 @@ +go-sqlite-tui +.crush/ +*.db diff --git a/README.md b/README.md new file mode 100644 index 0000000..5804982 --- /dev/null +++ b/README.md @@ -0,0 +1,66 @@ +# SQLite TUI + +A fully-featured terminal user interface for browsing SQLite databases built with Bubble Tea v2. + +## Features + +- **Table Browser**: Browse all tables in your SQLite database +- **Data Viewer**: View table data with pagination (20 rows per page) +- **SQL Query Interface**: Execute custom SQL queries with parameter support +- **Navigation**: Intuitive keyboard navigation +- **Responsive Design**: Adapts to terminal size + +## Usage + +```bash +go run main.go +``` + +Example with the included sample database: +```bash +go run main.go sample.db +``` + +## Keyboard Controls + +### Table List Mode +- `↑/↓` or `k/j`: Navigate between tables +- `Enter`: View selected table data +- `s`: Switch to SQL query mode +- `r`: Refresh table list +- `q` or `Ctrl+C`: Quit + +### Table Data Mode +- `←/→` or `h/l`: Navigate between pages +- `Esc`: Return to table list +- `r`: Refresh current table data +- `q` or `Ctrl+C`: Quit + +### SQL Query Mode +- Type your SQL query +- `Enter`: Execute query +- `Backspace`: Delete characters +- `Esc`: Return to table list +- `q` or `Ctrl+C`: Quit + +## Features Implemented + +1. **Table Browsing**: Lists all tables in the database with navigation +2. **Paginated Data View**: Shows table data with pagination (20 rows per page) +3. **SQL Query Execution**: Execute custom SQL queries and view results +4. **Error Handling**: Displays database errors gracefully +5. **Responsive UI**: Clean, styled interface that adapts to terminal size +6. **Column Information**: Shows column names and handles NULL values +7. **Navigation**: Intuitive keyboard shortcuts for all operations + +## Sample Database + +The included `sample.db` contains: +- `users` table with id, name, email, age columns +- `products` table with id, name, price, category columns + +## Dependencies + +- [Bubble Tea](https://github.com/charmbracelet/bubbletea) - TUI framework +- [Lip Gloss](https://github.com/charmbracelet/lipgloss) - Styling +- [go-sqlite3](https://github.com/mattn/go-sqlite3) - SQLite driver \ No newline at end of file diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..17f0b1b --- /dev/null +++ b/go.mod @@ -0,0 +1,27 @@ +module github.com/taigrr/go-sqlite-tui + +go 1.24.5 + +require ( + github.com/charmbracelet/bubbletea v1.2.4 + github.com/charmbracelet/lipgloss v1.0.0 + github.com/mattn/go-sqlite3 v1.14.24 +) + +require ( + github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect + github.com/charmbracelet/x/ansi v0.4.5 // indirect + github.com/charmbracelet/x/term v0.2.1 // indirect + github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect + github.com/lucasb-eyer/go-colorful v1.2.0 // indirect + github.com/mattn/go-isatty v0.0.20 // indirect + github.com/mattn/go-localereader v0.0.1 // indirect + github.com/mattn/go-runewidth v0.0.15 // indirect + github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 // indirect + github.com/muesli/cancelreader v0.2.2 // indirect + github.com/muesli/termenv v0.15.2 // indirect + github.com/rivo/uniseg v0.4.7 // indirect + golang.org/x/sync v0.9.0 // indirect + golang.org/x/sys v0.27.0 // indirect + golang.org/x/text v0.3.8 // indirect +) diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..13124e7 --- /dev/null +++ b/go.sum @@ -0,0 +1,39 @@ +github.com/aymanbagabas/go-osc52/v2 v2.0.1 h1:HwpRHbFMcZLEVr42D4p7XBqjyuxQH5SMiErDT4WkJ2k= +github.com/aymanbagabas/go-osc52/v2 v2.0.1/go.mod h1:uYgXzlJ7ZpABp8OJ+exZzJJhRNQ2ASbcXHWsFqH8hp8= +github.com/charmbracelet/bubbletea v1.2.4 h1:KN8aCViA0eps9SCOThb2/XPIlea3ANJLUkv3KnQRNCE= +github.com/charmbracelet/bubbletea v1.2.4/go.mod h1:Qr6fVQw+wX7JkWWkVyXYk/ZUQ92a6XNekLXa3rR18MM= +github.com/charmbracelet/lipgloss v1.0.0 h1:O7VkGDvqEdGi93X+DeqsQ7PKHDgtQfF8j8/O2qFMQNg= +github.com/charmbracelet/lipgloss v1.0.0/go.mod h1:U5fy9Z+C38obMs+T+tJqst9VGzlOYGj4ri9reL3qUlo= +github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSexTdRM= +github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= +github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= +github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4= +github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/lucasb-eyer/go-colorful v1.2.0 h1:1nnpGOrhyZZuNyfu1QjKiUICQ74+3FNCN69Aj6K7nkY= +github.com/lucasb-eyer/go-colorful v1.2.0/go.mod h1:R4dSotOR9KMtayYi1e77YzuveK+i7ruzyGqttikkLy0= +github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY= +github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y= +github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2JC/oIi4= +github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= +github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= +github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= +github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= +github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= +github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= +github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= +github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= +github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= +github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/rivo/uniseg v0.2.0/go.mod h1:J6wj4VEh+S6ZtnVlnTBMWIodfgj8LQOQFoIToxlJtxc= +github.com/rivo/uniseg v0.4.7 h1:WUdvkW8uEhrYfLC4ZzdpI2ztxP1I582+49Oc5Mq64VQ= +github.com/rivo/uniseg v0.4.7/go.mod h1:FN3SvrM+Zdj16jyLfmOkMNblXMcoc8DfTHruCPUcx88= +golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= +golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +golang.org/x/sys v0.0.0-20210809222454-d867a43fc93e/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= +golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY= +golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ= diff --git a/main.go b/main.go new file mode 100644 index 0000000..80a6e30 --- /dev/null +++ b/main.go @@ -0,0 +1,486 @@ +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 +) + +type viewMode int + +const ( + modeTableList viewMode = iota + modeTableData + modeQuery +) + +type model struct { + db *sql.DB + mode viewMode + tables []string + selectedTable int + tableData [][]string + columns []string + currentPage int + totalRows int + query string + queryInput string + cursor int + err error + width int + height int +} + +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")) + + tableStyle = lipgloss.NewStyle(). + Border(lipgloss.RoundedBorder()). + BorderForeground(lipgloss.Color("#874BFD")). + Padding(1, 2) +) + +func initialModel(dbPath string) model { + db, err := sql.Open("sqlite3", dbPath) + if err != nil { + return model{err: err} + } + + m := model{ + db: db, + mode: modeTableList, + currentPage: 0, + } + + m.loadTables() + return m +} + +func (m *model) loadTables() { + rows, err := m.db.Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + if err != nil { + m.err = err + return + } + defer rows.Close() + + m.tables = []string{} + for rows.Next() { + var name string + if err := rows.Scan(&name); err != nil { + m.err = err + return + } + m.tables = append(m.tables, name) + } +} + +func (m *model) loadTableData() { + if m.selectedTable >= len(m.tables) { + return + } + + tableName := m.tables[m.selectedTable] + + // Get column info + rows, err := m.db.Query(fmt.Sprintf("PRAGMA table_info(%s)", tableName)) + if err != nil { + m.err = err + return + } + defer rows.Close() + + m.columns = []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 { + m.err = err + return + } + m.columns = append(m.columns, name) + } + + // Get total row count + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName) + err = m.db.QueryRow(countQuery).Scan(&m.totalRows) + if err != nil { + m.err = err + return + } + + // Get paginated data + offset := m.currentPage * pageSize + dataQuery := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableName, pageSize, offset) + + rows, err = m.db.Query(dataQuery) + if err != nil { + m.err = err + return + } + defer rows.Close() + + m.tableData = [][]string{} + for rows.Next() { + values := make([]any, len(m.columns)) + valuePtrs := make([]any, len(m.columns)) + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + m.err = err + return + } + + row := make([]string, len(m.columns)) + for i, val := range values { + if val == nil { + row[i] = "NULL" + } else { + row[i] = fmt.Sprintf("%v", val) + } + } + m.tableData = append(m.tableData, row) + } +} + +func (m *model) executeQuery() { + if strings.TrimSpace(m.query) == "" { + return + } + + rows, err := m.db.Query(m.query) + if err != nil { + m.err = err + return + } + defer rows.Close() + + // Get column names + columns, err := rows.Columns() + if err != nil { + m.err = err + return + } + m.columns = columns + + // Get data + m.tableData = [][]string{} + for rows.Next() { + values := make([]any, len(m.columns)) + valuePtrs := make([]any, len(m.columns)) + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + m.err = err + return + } + + row := make([]string, len(m.columns)) + for i, val := range values { + if val == nil { + row[i] = "NULL" + } else { + row[i] = fmt.Sprintf("%v", val) + } + } + m.tableData = append(m.tableData, row) + } + + m.totalRows = len(m.tableData) + m.currentPage = 0 +} + +func (m model) Init() tea.Cmd { + return nil +} + +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 + + case tea.KeyMsg: + switch msg.String() { + case "ctrl+c", "q": + return m, tea.Quit + + case "esc": + switch m.mode { + case modeTableData, modeQuery: + m.mode = modeTableList + m.err = nil + } + + case "enter": + switch m.mode { + case modeTableList: + if len(m.tables) > 0 { + m.mode = modeTableData + m.currentPage = 0 + m.loadTableData() + } + case modeQuery: + m.executeQuery() + } + + case "up", "k": + switch m.mode { + case modeTableList: + if m.selectedTable > 0 { + m.selectedTable-- + } + } + + case "down", "j": + switch m.mode { + case modeTableList: + if m.selectedTable < len(m.tables)-1 { + m.selectedTable++ + } + } + + case "left", "h": + switch m.mode { + case modeTableData: + if m.currentPage > 0 { + m.currentPage-- + m.loadTableData() + } + } + + case "right", "l": + switch m.mode { + case modeTableData: + maxPage := (m.totalRows - 1) / pageSize + if m.currentPage < maxPage { + m.currentPage++ + m.loadTableData() + } + } + + case "s": + if m.mode == modeTableList { + m.mode = modeQuery + m.queryInput = "" + m.query = "" + m.cursor = 0 + } + + case "r": + if m.mode == modeTableList { + m.loadTables() + } else if m.mode == modeTableData { + m.loadTableData() + } + + case "backspace": + if m.mode == modeQuery && len(m.queryInput) > 0 { + m.queryInput = m.queryInput[:len(m.queryInput)-1] + m.query = m.queryInput + } + + default: + if m.mode == modeQuery { + m.queryInput += msg.String() + m.query = m.queryInput + } + } + } + + return m, nil +} + +func (m model) View() string { + if m.err != nil { + return errorStyle.Render(fmt.Sprintf("Error: %v\n\nPress 'esc' to continue or 'q' to quit", m.err)) + } + + var content strings.Builder + + switch m.mode { + case modeTableList: + content.WriteString(titleStyle.Render("SQLite TUI - Tables")) + content.WriteString("\n\n") + + if len(m.tables) == 0 { + content.WriteString("No tables found in database") + } else { + for i, table := range m.tables { + if i == m.selectedTable { + content.WriteString(selectedStyle.Render(fmt.Sprintf("> %s", table))) + } else { + content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", table))) + } + content.WriteString("\n") + } + } + + content.WriteString("\n") + content.WriteString(helpStyle.Render("↑/↓: navigate • enter: view table • s: SQL query • r: refresh • q: quit")) + + case modeTableData: + tableName := m.tables[m.selectedTable] + maxPage := (m.totalRows - 1) / pageSize + + content.WriteString(titleStyle.Render(fmt.Sprintf("Table: %s (Page %d/%d)", tableName, m.currentPage+1, maxPage+1))) + content.WriteString("\n\n") + + if len(m.tableData) == 0 { + content.WriteString("No data in table") + } else { + // Create table header + var headerRow strings.Builder + for i, col := range m.columns { + if i > 0 { + headerRow.WriteString(" | ") + } + headerRow.WriteString(fmt.Sprintf("%-15s", truncateString(col, 15))) + } + content.WriteString(selectedStyle.Render(headerRow.String())) + content.WriteString("\n") + + // Add separator + var separator strings.Builder + for i := range m.columns { + if i > 0 { + separator.WriteString("-+-") + } + separator.WriteString(strings.Repeat("-", 15)) + } + content.WriteString(separator.String()) + content.WriteString("\n") + + // Add data rows + for _, row := range m.tableData { + var dataRow strings.Builder + for i, cell := range row { + if i > 0 { + dataRow.WriteString(" | ") + } + dataRow.WriteString(fmt.Sprintf("%-15s", truncateString(cell, 15))) + } + content.WriteString(normalStyle.Render(dataRow.String())) + content.WriteString("\n") + } + } + + content.WriteString("\n") + content.WriteString(helpStyle.Render(fmt.Sprintf("←/→: prev/next page • Total rows: %d • esc: back • r: refresh • q: quit", m.totalRows))) + + case modeQuery: + content.WriteString(titleStyle.Render("SQL Query")) + content.WriteString("\n\n") + + content.WriteString("Query: ") + content.WriteString(m.queryInput) + content.WriteString("_") // cursor + content.WriteString("\n\n") + + if len(m.tableData) > 0 { + // Show query results + var headerRow strings.Builder + for i, col := range m.columns { + if i > 0 { + headerRow.WriteString(" | ") + } + headerRow.WriteString(fmt.Sprintf("%-15s", truncateString(col, 15))) + } + content.WriteString(selectedStyle.Render(headerRow.String())) + content.WriteString("\n") + + var separator strings.Builder + for i := range m.columns { + if i > 0 { + separator.WriteString("-+-") + } + separator.WriteString(strings.Repeat("-", 15)) + } + content.WriteString(separator.String()) + content.WriteString("\n") + + for _, row := range m.tableData { + var dataRow strings.Builder + for i, cell := range row { + if i > 0 { + dataRow.WriteString(" | ") + } + dataRow.WriteString(fmt.Sprintf("%-15s", truncateString(cell, 15))) + } + content.WriteString(normalStyle.Render(dataRow.String())) + content.WriteString("\n") + } + } + + content.WriteString("\n") + content.WriteString(helpStyle.Render("enter: execute query • esc: back • q: quit")) + } + + return tableStyle.Render(content.String()) +} + +func truncateString(s string, maxLen int) string { + if len(s) <= maxLen { + return s + } + return s[:maxLen-3] + "..." +} + +func main() { + if len(os.Args) < 2 { + fmt.Println("Usage: go-sqlite-tui ") + 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) + } +} \ No newline at end of file