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

View File

@@ -22,6 +22,7 @@ go run main.go <database.db>
```
Example with the included sample database:
```bash
go run main.go sample.db
```
@@ -29,6 +30,7 @@ go run main.go sample.db
## Keyboard Controls
### Table List Mode
- `↑/↓` or `k/j`: Navigate between tables
- `←/→` or `h/l`: Navigate between table list pages
- `/`: Start searching tables
@@ -38,11 +40,13 @@ go run main.go sample.db
- `q` or `Ctrl+C`: Quit
### Search Mode (when searching tables)
- Type to search table names
- `Enter` or `Esc`: Finish search
- `Backspace`: Delete characters
### Table Data Mode
- `↑/↓` or `k/j`: Navigate between data rows (with highlighting)
- `←/→` or `h/l`: Navigate between data pages
- `/`: Start searching within table data
@@ -52,17 +56,20 @@ go run main.go sample.db
- `q` or `Ctrl+C`: Quit
### Data Search Mode (when searching within table data)
- Type to search within all columns of the table
- `Enter` or `Esc`: Finish search
- `Backspace`: Delete characters
### Row Detail Modal
- `↑/↓` or `k/j`: Navigate between fields (Column | Value format)
- `Enter`: Edit selected field value
- `Esc`: Return to table data view
- `q` or `Ctrl+C`: Quit
### Cell Edit Mode
- **Readline-style Editing**: Full cursor control and advanced editing
- **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation
- **Line Navigation**: `Home`/`Ctrl+A` (start), `End`/`Ctrl+E` (end)
@@ -72,6 +79,7 @@ go run main.go sample.db
- `Esc`: Cancel editing and return to row detail
### SQL Query Mode
- **Advanced Text Editing**: Full readline-style editing controls
- **Dual Focus Mode**: Switch between query input and results with `Tab`
- **Query Input Focus**:
@@ -100,16 +108,16 @@ go run main.go sample.db
7. **Cell Editing**: Live editing of individual cell values with database updates (works for both table data and query results)
8. **Smart Query Analysis**: Automatically detects source tables from simple queries to enable editing
9. **Readline-style Editing**: Full cursor control with word navigation, line navigation, and advanced deletion
9. **Text Wrapping**: Long values are automatically wrapped in edit and detail views
10. **Primary Key Detection**: Uses primary keys for reliable row updates
11. **Screen-Aware Display**: Content automatically fits terminal size
12. **SQL Query Execution**: Execute custom SQL queries with advanced text editing and dual-focus mode
13. **Query Results Navigation**: Navigate and interact with query results just like table data
14. **Error Handling**: Displays database errors gracefully
15. **Responsive UI**: Clean, styled interface that adapts to terminal size
16. **Column Information**: Shows column names and handles NULL values
17. **Navigation**: Intuitive keyboard shortcuts for all operations
18. **Dynamic Column Width**: Columns adjust to terminal width
10. **Text Wrapping**: Long values are automatically wrapped in edit and detail views
11. **Primary Key Detection**: Uses primary keys for reliable row updates
12. **Screen-Aware Display**: Content automatically fits terminal size
13. **SQL Query Execution**: Execute custom SQL queries with advanced text editing and dual-focus mode
14. **Query Results Navigation**: Navigate and interact with query results just like table data
15. **Error Handling**: Displays database errors gracefully
16. **Responsive UI**: Clean, styled interface that adapts to terminal size
17. **Column Information**: Shows column names and handles NULL values
18. **Navigation**: Intuitive keyboard shortcuts for all operations
19. **Dynamic Column Width**: Columns adjust to terminal width
## Navigation Flow
@@ -124,6 +132,7 @@ SQL Query Row Select Cell Select Save/Cancel
## Sample Database
The included `sample.db` contains:
- `users` table with id, name, email, age columns
- `products` table with id, name, price, category columns
@@ -136,6 +145,7 @@ The included `sample.db` contains:
## Database Updates
The application supports live editing of database records:
- Uses primary keys when available for reliable row identification
- Falls back to full-row matching when no primary key exists
- Updates are immediately reflected in the interface

59
cmd/root.go Normal file
View File

@@ -0,0 +1,59 @@
package cmd
import (
"context"
"database/sql"
"fmt"
"os"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/fang"
"github.com/spf13/cobra"
_ "modernc.org/sqlite"
"github.com/taigrr/teaqlite/internal/app"
)
var (
dbPath string
)
var rootCmd = &cobra.Command{
Use: "teaqlite [database.db]",
Short: "A TUI for SQLite databases",
Long: `TeaQLite is a terminal user interface for browsing and editing SQLite databases.`,
Args: cobra.ExactArgs(1),
RunE: func(cmd *cobra.Command, args []string) error {
dbPath = args[0]
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return fmt.Errorf("database file '%s' does not exist", dbPath)
}
db, err := sql.Open("sqlite", dbPath)
if err != nil {
return fmt.Errorf("failed to open database: %w", err)
}
defer db.Close()
m := app.InitialModel(db)
if m.Err() != nil {
return m.Err()
}
p := tea.NewProgram(m, tea.WithAltScreen())
if _, err := p.Run(); err != nil {
return fmt.Errorf("failed to run TUI: %w", err)
}
return nil
},
}
func Execute() error {
return fang.Execute(context.Background(), rootCmd)
}
func init() {
rootCmd.Flags().StringVarP(&dbPath, "database", "d", "", "Path to SQLite database file")
}

View File

@@ -1,212 +0,0 @@
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Edit Cell Model
type editCellModel struct {
shared *sharedData
rowIndex int
colIndex int
editingValue string
originalValue string
cursorPos int
}
func newEditCellModel(shared *sharedData, rowIndex, colIndex int) *editCellModel {
originalValue := ""
if rowIndex < len(shared.filteredData) && colIndex < len(shared.filteredData[rowIndex]) {
originalValue = shared.filteredData[rowIndex][colIndex]
}
return &editCellModel{
shared: shared,
rowIndex: rowIndex,
colIndex: colIndex,
editingValue: originalValue,
originalValue: originalValue,
cursorPos: len(originalValue), // Start cursor at end
}
}
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:
return m.handleInput(msg)
}
return m, nil
}
func (m *editCellModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
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.editingValue,
}
}
// Cursor movement
case "left":
if m.cursorPos > 0 {
m.cursorPos--
}
case "right":
if m.cursorPos < len(m.editingValue) {
m.cursorPos++
}
case "ctrl+left":
m.cursorPos = m.wordLeft(m.cursorPos)
case "ctrl+right":
m.cursorPos = m.wordRight(m.cursorPos)
case "home", "ctrl+a":
m.cursorPos = 0
case "end", "ctrl+e":
m.cursorPos = len(m.editingValue)
// Deletion
case "backspace":
if m.cursorPos > 0 {
m.editingValue = m.editingValue[:m.cursorPos-1] + m.editingValue[m.cursorPos:]
m.cursorPos--
}
case "delete", "ctrl+d":
if m.cursorPos < len(m.editingValue) {
m.editingValue = m.editingValue[:m.cursorPos] + m.editingValue[m.cursorPos+1:]
}
case "ctrl+w":
// Delete word backward
newPos := m.wordLeft(m.cursorPos)
m.editingValue = m.editingValue[:newPos] + m.editingValue[m.cursorPos:]
m.cursorPos = newPos
case "ctrl+k":
// Delete from cursor to end of line
m.editingValue = m.editingValue[:m.cursorPos]
case "ctrl+u":
// Delete from beginning of line to cursor
m.editingValue = m.editingValue[m.cursorPos:]
m.cursorPos = 0
default:
// Insert character at cursor position
if len(msg.String()) == 1 {
m.editingValue = m.editingValue[:m.cursorPos] + msg.String() + m.editingValue[m.cursorPos:]
m.cursorPos++
}
}
return m, nil
}
// Helper functions for word navigation (same as query model)
func (m *editCellModel) wordLeft(pos int) int {
if pos == 0 {
return 0
}
// Skip whitespace
for pos > 0 && isWhitespace(m.editingValue[pos-1]) {
pos--
}
// Skip non-whitespace
for pos > 0 && !isWhitespace(m.editingValue[pos-1]) {
pos--
}
return pos
}
func (m *editCellModel) wordRight(pos int) int {
length := len(m.editingValue)
if pos >= length {
return length
}
// Skip non-whitespace
for pos < length && !isWhitespace(m.editingValue[pos]) {
pos++
}
// Skip whitespace
for pos < length && isWhitespace(m.editingValue[pos]) {
pos++
}
return pos
}
func (m *editCellModel) View() string {
var content strings.Builder
tableName := m.shared.filteredTables[m.shared.selectedTable]
columnName := ""
if m.colIndex < len(m.shared.columns) {
columnName = m.shared.columns[m.colIndex]
}
content.WriteString(titleStyle.Render(fmt.Sprintf("Edit: %s.%s", tableName, columnName)))
content.WriteString("\n\n")
// Calculate available width for text (leave some margin)
textWidth := max(20, m.shared.width-4)
// Wrap original value
content.WriteString("Original:")
content.WriteString("\n")
originalLines := wrapText(m.originalValue, textWidth)
for _, line := range originalLines {
content.WriteString(" " + line)
content.WriteString("\n")
}
content.WriteString("\n")
// Wrap new value with cursor
content.WriteString("New:")
content.WriteString("\n")
// Display editing value with cursor
valueWithCursor := ""
if m.cursorPos <= len(m.editingValue) {
before := m.editingValue[:m.cursorPos]
after := m.editingValue[m.cursorPos:]
valueWithCursor = before + "█" + after
} else {
valueWithCursor = m.editingValue + "█"
}
newLines := wrapText(valueWithCursor, textWidth)
for _, line := range newLines {
content.WriteString(" " + line)
content.WriteString("\n")
}
content.WriteString("\n")
content.WriteString(helpStyle.Render("←/→: move cursor • ctrl+←/→: word nav • home/end: line nav • ctrl+w/k/u: delete • enter: save • esc: cancel"))
return content.String()
}

34
go.mod
View File

@@ -3,33 +3,47 @@ module github.com/taigrr/teaqlite
go 1.24.5
require (
github.com/charmbracelet/bubbletea v1.2.4
github.com/charmbracelet/lipgloss v1.0.0
github.com/charmbracelet/bubbletea v1.3.6
github.com/charmbracelet/fang v0.3.0
github.com/charmbracelet/lipgloss v1.1.0
github.com/spf13/cobra v1.9.1
modernc.org/sqlite v1.38.0
)
require (
github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect
github.com/charmbracelet/x/ansi v0.4.5 // indirect
github.com/charmbracelet/colorprofile v0.3.1 // indirect
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 // indirect
github.com/charmbracelet/x/ansi v0.9.3 // indirect
github.com/charmbracelet/x/cellbuf v0.0.13 // indirect
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e // indirect
github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e // indirect
github.com/charmbracelet/x/term v0.2.1 // indirect
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f // indirect
github.com/google/uuid v1.6.0 // indirect
github.com/inconshreveable/mousetrap v1.1.0 // 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/mattn/go-runewidth v0.0.16 // 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/muesli/mango v0.2.0 // indirect
github.com/muesli/mango-cobra v1.2.0 // indirect
github.com/muesli/mango-pflag v0.1.0 // indirect
github.com/muesli/roff v0.1.0 // indirect
github.com/muesli/termenv v0.16.0 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rivo/uniseg v0.4.7 // indirect
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 // indirect
golang.org/x/sync v0.14.0 // indirect
golang.org/x/sys v0.33.0 // indirect
golang.org/x/text v0.3.8 // indirect
modernc.org/libc v1.65.10 // indirect
github.com/spf13/pflag v1.0.6 // indirect
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e // indirect
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.34.0 // indirect
golang.org/x/text v0.27.0 // indirect
modernc.org/libc v1.66.3 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)

81
go.sum
View File

@@ -1,13 +1,42 @@
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/aymanbagabas/go-udiff v0.2.0 h1:TK0fH4MteXUDspT88n8CKzvK0X9O2xu9yQjWpi6yML8=
github.com/aymanbagabas/go-udiff v0.2.0/go.mod h1:RE4Ex0qsGkTAJoQdQQCA0uG+nAzJO/pI/QwceO5fgrA=
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/bubbletea v1.3.6 h1:VkHIxPJQeDt0aFJIsVxw8BQdh/F/L2KKZGsK6et5taU=
github.com/charmbracelet/bubbletea v1.3.6/go.mod h1:oQD9VCRQFF8KplacJLo28/jofOI2ToOfGYeFgBBxHOc=
github.com/charmbracelet/colorprofile v0.3.1 h1:k8dTHMd7fgw4bnFd7jXTLZrSU/CQrKnL3m+AxCzDz40=
github.com/charmbracelet/colorprofile v0.3.1/go.mod h1:/GkGusxNs8VB/RSOh3fu0TJmQ4ICMMPApIIVn0KszZ0=
github.com/charmbracelet/fang v0.3.0 h1:Be6TB+ExS8VWizTQRJgjqbJBudKrmVUet65xmFPGhaA=
github.com/charmbracelet/fang v0.3.0/go.mod h1:b0ZfEXZeBds0I27/wnTfnv2UVigFDXHhrFNwQztfA0M=
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/lipgloss v1.1.0 h1:vYXsiLHVkK7fp74RkV7b2kq9+zDLoEU4MZoFqR/noCY=
github.com/charmbracelet/lipgloss v1.1.0/go.mod h1:/6Q8FR2o+kj8rz4Dq0zQc3vYf7X+B0binUUBwA0aL30=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2 h1:vq2enzx1Hr3UenVefpPEf+E2xMmqtZoSHhx8IE+V8ug=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta.2/go.mod h1:EJWvaCrhOhNGVZMvcjc0yVryl4qqpMs8tz0r9WyEkdQ=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1 h1:SOylT6+BQzPHEjn15TIzawBPVD0QmhKXbcb3jY0ZIKU=
github.com/charmbracelet/lipgloss/v2 v2.0.0-beta1/go.mod h1:tRlx/Hu0lo/j9viunCN2H+Ze6JrmdjQlXUQvvArgaOc=
github.com/charmbracelet/x/ansi v0.8.0 h1:9GTq3xq9caJW8ZrBTe0LIe2fvfLR/bYXKTx2llXn7xE=
github.com/charmbracelet/x/ansi v0.8.0/go.mod h1:wdYl/ONOLHLIVmQaxbIYEC/cRKOQyjTkowiI4blgS9Q=
github.com/charmbracelet/x/ansi v0.9.3 h1:BXt5DHS/MKF+LjuK4huWrC6NCvHtexww7dMayh6GXd0=
github.com/charmbracelet/x/ansi v0.9.3/go.mod h1:3RQDQ6lDnROptfpWuUVIUG64bD2g2BgntdxH0Ya5TeE=
github.com/charmbracelet/x/cellbuf v0.0.13 h1:/KBBKHuVRbq1lYx5BzEHBAFBP8VcQzJejZ/IA3iR28k=
github.com/charmbracelet/x/cellbuf v0.0.13/go.mod h1:xe0nKWGd3eJgtqZRaN9RjMtK7xUYchjzPr7q6kcvCCs=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444 h1:IJDiTgVE56gkAGfq0lBEloWgkXMk4hl/bmuPoicI4R0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250603201427-c31516f43444/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e h1:sc41kBOnun1OX15Lg05ZB6Ly6AFWnntCYb8jsEDBAPs=
github.com/charmbracelet/x/exp/charmtone v0.0.0-20250711012602-b1f986320f7e/go.mod h1:T9jr8CzFpjhFVHjNjKwbAD7KwBNyFnj2pntAO7F2zw0=
github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e h1:D0tltuLCSvxMznOpQg7f3MArp8ImU0zALbakI47ffkw=
github.com/charmbracelet/x/exp/color v0.0.0-20250711012602-b1f986320f7e/go.mod h1:hk/GyTELmEgX54pBAOHcFvH8Xed53JWo/g8kJXFo/PI=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a h1:G99klV19u0QnhiizODirwVksQB91TJKV/UaTnACcG30=
github.com/charmbracelet/x/exp/golden v0.0.0-20240806155701-69247e0abc2a/go.mod h1:wDlXFlCrmJ8J+swcL/MnGUuYnqgQdW9rhSD61oNMb6U=
github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ=
github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg=
github.com/cpuguy83/go-md2man/v2 v2.0.6/go.mod h1:oOW0eioCTA6cOiMLiUPZOpcVxMig6NIQQ7OS05n1F4g=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f h1:Y/CXytFA4m6baUTXGLOoWe4PQhGxaX0KpnayAqC48p4=
@@ -16,41 +45,79 @@ github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17k
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/inconshreveable/mousetrap v1.1.0 h1:wN+x4NVGpMsO7ErUn/mUI3vEoE6Jt13X2s0bqwp9tc8=
github.com/inconshreveable/mousetrap v1.1.0/go.mod h1:vpF70FUmC8bwa3OWnCshd2FqLfsEA9PFc4w1p2J65bw=
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-runewidth v0.0.16 h1:E5ScNMtiwvlvB5paMFdw9p4kSQzbXFikJ5SQO6TULQc=
github.com/mattn/go-runewidth v0.0.16/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w=
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/mango v0.1.0 h1:DZQK45d2gGbql1arsYA4vfg4d7I9Hfx5rX/GCmzsAvI=
github.com/muesli/mango v0.1.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango v0.2.0 h1:iNNc0c5VLQ6fsMgAqGQofByNUBH2Q2nEbD6TaI+5yyQ=
github.com/muesli/mango v0.2.0/go.mod h1:5XFpbC8jY5UUv89YQciiXNlbi+iJgt29VDC5xbzrLL4=
github.com/muesli/mango-cobra v1.2.0 h1:DQvjzAM0PMZr85Iv9LIMaYISpTOliMEg+uMFtNbYvWg=
github.com/muesli/mango-cobra v1.2.0/go.mod h1:vMJL54QytZAJhCT13LPVDfkvCUJ5/4jNUKF/8NC2UjA=
github.com/muesli/mango-pflag v0.1.0 h1:UADqbYgpUyRoBja3g6LUL+3LErjpsOwaC9ywvBWe7Sg=
github.com/muesli/mango-pflag v0.1.0/go.mod h1:YEQomTxaCUp8PrbhFh10UfbhbQrM/xJ4i2PB8VTLLW0=
github.com/muesli/roff v0.1.0 h1:YD0lalCotmYuF5HhZliKWlIx7IEhiXeSfq7hNjFqGF8=
github.com/muesli/roff v0.1.0/go.mod h1:pjAHQM9hdUUwm/krAfrLGgJkXJ+YuhtsfZ42kieB2Ig=
github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo=
github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8=
github.com/muesli/termenv v0.16.0 h1:S5AlUN9dENB57rsbnkPyfdGuWIlkmzJjbFf0Tf5FWUc=
github.com/muesli/termenv v0.16.0/go.mod h1:ZRfOIKPFDYQoDFF4Olj7/QJbW60Ol/kL1pU3VfY/Cnk=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
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=
github.com/russross/blackfriday/v2 v2.1.0/go.mod h1:+Rmxgy9KzJVeS9/2gXHxylqXiyQDYRxCVz55jmeOWTM=
github.com/spf13/cobra v1.9.1 h1:CXSaggrXdbHK9CF+8ywj8Amf7PBRmPCOJugH954Nnlo=
github.com/spf13/cobra v1.9.1/go.mod h1:nDyEzZ8ogv936Cinf6g1RU9MRY64Ir93oCnqb9wxYW0=
github.com/spf13/pflag v1.0.6 h1:jFzHGLGAlb3ruxLB8MhbI6A8+AQX/2eW4qeyNZXNp2o=
github.com/spf13/pflag v1.0.6/go.mod h1:McXfInJRrz4CZXVZOBLb0bTZqETkiAhM9Iw0y3An2Bg=
github.com/stretchr/testify v1.10.0 h1:Xv5erBjTwe/5IxqUQTdXv5kgmIvbHo3QQyRwhJsOfJA=
github.com/stretchr/testify v1.10.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e h1:JVG44RsyaB9T2KIHavMF/ppJZNG9ZpyihvCd0w101no=
github.com/xo/terminfo v0.0.0-20220910002029-abceb7e1c41e/go.mod h1:RbqR21r5mrJuqunuUZ/Dhy/avygyECGrLceyNeo4LiM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0 h1:R84qjqJb5nVJMxqWYb3np9L5ZsaDtB+a39EqjV0JSUM=
golang.org/x/exp v0.0.0-20250408133849-7e4ce0ab07d0/go.mod h1:S9Xr4PYopiDyqSyp5NjCrhFrqg6A5zA2E/iPHPhqnS8=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc h1:TS73t7x3KarrNd5qAipmspBDS1rkMcgVG/fS1aRb4Rc=
golang.org/x/exp v0.0.0-20250711185948-6ae5c78190dc/go.mod h1:A+z0yzpGtvnG90cToK5n2tu8UJVP2XUATh+r+sfOOOc=
golang.org/x/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU=
golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww=
golang.org/x/mod v0.26.0 h1:EGMPT//Ezu+ylkCijjPc+f4Aih7sZvaAr+O3EHBxvZg=
golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ=
golang.org/x/sync v0.14.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
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.33.0 h1:q3i8TbbEz+JRD9ywIRlyRAQbM0qF7hu24q3teo2hbuw=
golang.org/x/sys v0.33.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.3.8 h1:nAL+RVCQ9uMn3vJZbV+MRnydTJFPf8qqY42YiA6MrqY=
golang.org/x/text v0.3.8/go.mod h1:E6s5w1FMmriuDzIBO73fBruAKo1PCIq6d2Q6DHfQ8WQ=
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
golang.org/x/text v0.24.0 h1:dd5Bzh4yt5KYA8f9CJHCP4FB4D51c2c6JvN37xJJkJ0=
golang.org/x/text v0.24.0/go.mod h1:L8rBsPeo2pSS+xqN0d5u2ikmjtmoJbDBT1b7nHvFCdU=
golang.org/x/text v0.27.0 h1:4fGWRpyh641NLlecmyl4LOe6yDdfaYNrGb2zdfo4JV4=
golang.org/x/text v0.27.0/go.mod h1:1D28KMCvyooCX9hBiosv5Tz/+YLxj0j7XhWjpSUF7CU=
golang.org/x/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc=
golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI=
golang.org/x/tools v0.35.0 h1:mBffYraMEf7aa0sB+NuKnuCy8qI/9Bughn8dC2Gu5r0=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.1 h1:+X5NtzVBn0KgsBCBe+xkDC7twLb/jNVj9FPgiwSQO3s=
modernc.org/cc/v4 v4.26.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.0 h1:rjznn6WWehKq7dG4JtLRKxb52Ecv8OUGah8+Z/SfpNU=
@@ -61,6 +128,8 @@ modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/libc v1.65.10 h1:ZwEk8+jhW7qBjHIT+wd0d9VjitRyQef9BnzlzGwMODc=
modernc.org/libc v1.65.10/go.mod h1:StFvYpx7i/mXtBAfVOjaU0PWZOvIRoZSgXhrwXzr8Po=
modernc.org/libc v1.66.3 h1:cfCbjTUcdsKyyZZfEUKfoHcP3S0Wkvz3jgSzByEWVCQ=
modernc.org/libc v1.66.3/go.mod h1:XD9zO8kt59cANKvHPXpx7yS2ELPheAey0vjIuZOhOU8=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=

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()
}

View File

@@ -1,4 +1,4 @@
package main
package app
import (
"fmt"
@@ -8,27 +8,27 @@ import (
)
// Table List Model
type tableListModel struct {
shared *sharedData
type TableListModel struct {
Shared *SharedData
searchInput string
searching bool
selectedTable int
currentPage int
}
func newTableListModel(shared *sharedData) *tableListModel {
return &tableListModel{
shared: shared,
func NewTableListModel(shared *SharedData) *TableListModel {
return &TableListModel{
Shared: shared,
selectedTable: 0,
currentPage: 0,
}
}
func (m *tableListModel) Init() tea.Cmd {
func (m *TableListModel) Init() tea.Cmd {
return nil
}
func (m *tableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
func (m *TableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
switch msg := msg.(type) {
case tea.KeyMsg:
if m.searching {
@@ -39,7 +39,7 @@ func (m *tableListModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) {
return m, nil
}
func (m *tableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *TableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc", "enter":
m.searching = false
@@ -58,7 +58,7 @@ func (m *tableListModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd)
return m, nil
}
func (m *tableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
func (m *TableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "/":
m.searching = true
@@ -66,17 +66,17 @@ func (m *tableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
return m, nil
case "enter":
if len(m.shared.filteredTables) > 0 {
if len(m.Shared.FilteredTables) > 0 {
return m, func() tea.Msg {
return switchToTableDataMsg{tableIndex: m.selectedTable}
return SwitchToTableDataMsg{TableIndex: m.selectedTable}
}
}
case "s":
return m, func() tea.Msg { return switchToQueryMsg{} }
return m, func() tea.Msg { return SwitchToQueryMsg{} }
case "r":
if err := m.shared.loadTables(); err == nil {
if err := m.Shared.LoadTables(); err == nil {
m.filterTables()
}
@@ -87,7 +87,7 @@ func (m *tableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case "down", "j":
if m.selectedTable < len(m.shared.filteredTables)-1 {
if m.selectedTable < len(m.Shared.FilteredTables)-1 {
m.selectedTable++
m.adjustPage()
}
@@ -99,55 +99,55 @@ func (m *tableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
}
case "right", "l":
maxPage := (len(m.shared.filteredTables) - 1) / m.getVisibleCount()
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
if m.selectedTable >= len(m.Shared.FilteredTables) {
m.selectedTable = len(m.Shared.FilteredTables) - 1
}
}
}
return m, nil
}
func (m *tableListModel) filterTables() {
func (m *TableListModel) filterTables() {
if m.searchInput == "" {
m.shared.filteredTables = make([]string, len(m.shared.tables))
copy(m.shared.filteredTables, m.shared.tables)
m.Shared.FilteredTables = make([]string, len(m.Shared.Tables))
copy(m.Shared.FilteredTables, m.Shared.Tables)
} else {
m.shared.filteredTables = []string{}
m.Shared.FilteredTables = []string{}
searchLower := strings.ToLower(m.searchInput)
for _, table := range m.shared.tables {
for _, table := range m.Shared.Tables {
if strings.Contains(strings.ToLower(table), searchLower) {
m.shared.filteredTables = append(m.shared.filteredTables, table)
m.Shared.FilteredTables = append(m.Shared.FilteredTables, table)
}
}
}
if m.selectedTable >= len(m.shared.filteredTables) {
if m.selectedTable >= len(m.Shared.FilteredTables) {
m.selectedTable = 0
m.currentPage = 0
}
}
func (m *tableListModel) getVisibleCount() int {
func (m *TableListModel) getVisibleCount() int {
reservedLines := 8
if m.searching {
reservedLines += 2
}
return max(1, m.shared.height-reservedLines)
return Max(1, m.Shared.Height-reservedLines)
}
func (m *tableListModel) adjustPage() {
func (m *TableListModel) adjustPage() {
visibleCount := m.getVisibleCount()
m.currentPage = m.selectedTable / visibleCount
}
func (m *tableListModel) View() string {
func (m *TableListModel) View() string {
var content strings.Builder
content.WriteString(titleStyle.Render("SQLite TUI - Tables"))
content.WriteString(TitleStyle.Render("SQLite TUI - Tables"))
content.WriteString("\n")
if m.searching {
@@ -155,12 +155,12 @@ func (m *tableListModel) View() string {
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)))
m.searchInput, len(m.Shared.FilteredTables), len(m.Shared.Tables)))
content.WriteString("\n")
}
content.WriteString("\n")
if len(m.shared.filteredTables) == 0 {
if len(m.Shared.FilteredTables) == 0 {
if m.searchInput != "" {
content.WriteString("No tables match your search")
} else {
@@ -169,29 +169,29 @@ func (m *tableListModel) View() string {
} else {
visibleCount := m.getVisibleCount()
startIdx := m.currentPage * visibleCount
endIdx := min(startIdx+visibleCount, len(m.shared.filteredTables))
endIdx := Min(startIdx+visibleCount, len(m.Shared.FilteredTables))
for i := startIdx; i < endIdx; i++ {
table := m.shared.filteredTables[i]
table := m.Shared.FilteredTables[i]
if i == m.selectedTable {
content.WriteString(selectedStyle.Render(fmt.Sprintf("> %s", table)))
content.WriteString(SelectedStyle.Render(fmt.Sprintf("> %s", table)))
} else {
content.WriteString(normalStyle.Render(fmt.Sprintf(" %s", table)))
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
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"))
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"))
content.WriteString(HelpStyle.Render("↑/↓: navigate • ←/→: page • /: search • enter: view • s: SQL • r: refresh • ctrl+c: quit"))
}
return content.String()

622
main.go
View File

@@ -1,631 +1,13 @@
package main
import (
"database/sql"
"fmt"
"log"
"os"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea"
"github.com/charmbracelet/lipgloss"
_ "modernc.org/sqlite" // Import SQLite driver
"github.com/taigrr/teaqlite/cmd"
)
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 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 {
if err := cmd.Execute(); err != nil {
log.Fatal(err)
}
}
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(dbPath string) model {
db, err := sql.Open("sqlite", 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" {
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) 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)
}
}

434
query.go
View File

@@ -1,434 +0,0 @@
package main
import (
"fmt"
"slices"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Query Model
type queryModel struct {
shared *sharedData
queryInput string
cursorPos int
results [][]string
columns []string
focusOnInput bool // true = input focused, false = results focused
selectedRow int
errorMsg string // Error message to display
}
func newQueryModel(shared *sharedData) *queryModel {
return &queryModel{
shared: shared,
focusOnInput: true, // Start with input focused
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:
return m.handleInput(msg)
}
return m, nil
}
func (m *queryModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
// Clear error message if present, otherwise go back
if m.errorMsg != "" {
m.errorMsg = ""
return m, nil
}
return m, func() tea.Msg { return switchToTableListMsg{} }
case "tab":
// Switch focus between input and results
if len(m.results) > 0 {
m.focusOnInput = !m.focusOnInput
if !m.focusOnInput {
// Reset row selection when switching to results
m.selectedRow = 0
}
}
return m, nil
case "enter":
if m.focusOnInput {
// Execute query when input is focused
if err := m.executeQuery(); err != nil {
m.errorMsg = err.Error()
} else {
m.errorMsg = "" // Clear error on successful query
}
} else {
// View row detail when results are focused
if len(m.results) > 0 && m.selectedRow < len(m.results) {
// Convert query results to shared data format for row detail view
m.shared.filteredData = m.results
m.shared.columns = m.columns
m.shared.isQueryResult = true
// Try to detect if this is a simple single-table query
m.shared.queryTableName = m.detectSourceTable()
return m, func() tea.Msg {
return switchToRowDetailFromQueryMsg{rowIndex: m.selectedRow}
}
}
}
return m, nil
}
// Handle input-specific controls
if m.focusOnInput {
return m.handleInputControls(msg)
} else {
return m.handleResultsNavigation(msg)
}
}
func (m *queryModel) handleInputControls(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
// Cursor movement
case "left":
if m.cursorPos > 0 {
m.cursorPos--
}
case "right":
if m.cursorPos < len(m.queryInput) {
m.cursorPos++
}
case "ctrl+left":
m.cursorPos = m.wordLeft(m.cursorPos)
case "ctrl+right":
m.cursorPos = m.wordRight(m.cursorPos)
case "home", "ctrl+a":
m.cursorPos = 0
case "end", "ctrl+e":
m.cursorPos = len(m.queryInput)
// Deletion
case "backspace":
if m.cursorPos > 0 {
m.queryInput = m.queryInput[:m.cursorPos-1] + m.queryInput[m.cursorPos:]
m.cursorPos--
}
case "delete", "ctrl+d":
if m.cursorPos < len(m.queryInput) {
m.queryInput = m.queryInput[:m.cursorPos] + m.queryInput[m.cursorPos+1:]
}
case "ctrl+w":
// Delete word backward
newPos := m.wordLeft(m.cursorPos)
m.queryInput = m.queryInput[:newPos] + m.queryInput[m.cursorPos:]
m.cursorPos = newPos
case "ctrl+k":
// Delete from cursor to end of line
m.queryInput = m.queryInput[:m.cursorPos]
case "ctrl+u":
// Delete from beginning of line to cursor
m.queryInput = m.queryInput[m.cursorPos:]
m.cursorPos = 0
default:
// Insert character at cursor position
if len(msg.String()) == 1 {
m.queryInput = m.queryInput[:m.cursorPos] + msg.String() + m.queryInput[m.cursorPos:]
m.cursorPos++
}
}
return m, nil
}
func (m *queryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "up", "k":
if m.selectedRow > 0 {
m.selectedRow--
}
case "down", "j":
if m.selectedRow < len(m.results)-1 {
m.selectedRow++
}
case "home":
m.selectedRow = 0
case "end":
if len(m.results) > 0 {
m.selectedRow = len(m.results) - 1
}
// Page navigation
case "page_up":
m.selectedRow = max(0, m.selectedRow-10)
case "page_down":
m.selectedRow = min(len(m.results)-1, m.selectedRow+10)
}
return m, nil
}
// Helper functions for word navigation
func (m *queryModel) wordLeft(pos int) int {
if pos == 0 {
return 0
}
// Skip whitespace
for pos > 0 && isWhitespace(m.queryInput[pos-1]) {
pos--
}
// Skip non-whitespace
for pos > 0 && !isWhitespace(m.queryInput[pos-1]) {
pos--
}
return pos
}
func (m *queryModel) wordRight(pos int) int {
length := len(m.queryInput)
if pos >= length {
return length
}
// Skip non-whitespace
for pos < length && !isWhitespace(m.queryInput[pos]) {
pos++
}
// Skip whitespace
for pos < length && isWhitespace(m.queryInput[pos]) {
pos++
}
return pos
}
func isWhitespace(r byte) bool {
return r == ' ' || r == '\t' || r == '\n' || r == '\r'
}
func (m *queryModel) executeQuery() error {
if strings.TrimSpace(m.queryInput) == "" {
return nil
}
rows, err := m.shared.db.Query(m.queryInput)
if err != nil {
return err
}
defer rows.Close()
// Get column names
columns, err := rows.Columns()
if err != nil {
return err
}
m.columns = columns
// Get data
m.results = [][]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 {
return err
}
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.results = append(m.results, row)
}
// Reset selection when new results are loaded
m.selectedRow = 0
// Keep focus on input after executing query
m.focusOnInput = true
return nil
}
func (m *queryModel) getVisibleRowCount() int {
reservedLines := 9
return max(1, m.shared.height-reservedLines)
}
func (m *queryModel) View() string {
var content strings.Builder
content.WriteString(titleStyle.Render("SQL Query"))
content.WriteString("\n\n")
// Display error modal if there's an error
if m.errorMsg != "" {
errorBox := errorStyle.Render(fmt.Sprintf("Error: %s", m.errorMsg))
content.WriteString(errorBox)
content.WriteString("\n\n")
content.WriteString(helpStyle.Render("esc: dismiss error"))
return content.String()
}
// Display query with cursor and focus indicator
if m.focusOnInput {
content.WriteString("Query: ")
} else {
content.WriteString(helpStyle.Render("Query: "))
}
if m.focusOnInput {
if m.cursorPos <= len(m.queryInput) {
before := m.queryInput[:m.cursorPos]
after := m.queryInput[m.cursorPos:]
content.WriteString(before)
content.WriteString("█") // Block cursor
content.WriteString(after)
} else {
content.WriteString(m.queryInput)
content.WriteString("█")
}
} else {
content.WriteString(m.queryInput)
}
content.WriteString("\n\n")
if len(m.results) > 0 {
// Show results header with focus indicator
if !m.focusOnInput {
content.WriteString(titleStyle.Render("Results (focused)"))
} else {
content.WriteString(helpStyle.Render("Results"))
}
content.WriteString("\n")
// Limit rows to fit screen
visibleRows := m.getVisibleRowCount() - 2 // Account for results header
displayRows := min(len(m.results), visibleRows)
// Show query results
colWidth := 10
if len(m.columns) > 0 && m.shared.width > 0 {
colWidth = max(10, (m.shared.width-len(m.columns)*3)/len(m.columns))
}
var headerRow strings.Builder
for i, col := range m.columns {
if i > 0 {
headerRow.WriteString(" | ")
}
headerRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(col, colWidth)))
}
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("-", colWidth))
}
content.WriteString(separator.String())
content.WriteString("\n")
for i := range displayRows {
row := m.results[i]
var dataRow strings.Builder
for j, cell := range row {
if j > 0 {
dataRow.WriteString(" | ")
}
dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth)))
}
// Highlight selected row when results are focused
if !m.focusOnInput && i == m.selectedRow {
content.WriteString(selectedStyle.Render(dataRow.String()))
} else {
content.WriteString(normalStyle.Render(dataRow.String()))
}
content.WriteString("\n")
}
if len(m.results) > displayRows {
content.WriteString(helpStyle.Render(fmt.Sprintf("... and %d more rows", len(m.results)-displayRows)))
content.WriteString("\n")
}
}
content.WriteString("\n")
if m.focusOnInput {
content.WriteString(helpStyle.Render("enter: execute • tab: focus results • ←/→: cursor • ctrl+←/→: word nav • home/end: line nav • esc: back"))
} else {
content.WriteString(helpStyle.Render("↑/↓: select row • enter: view row • tab: focus input • esc: back"))
}
return content.String()
}
// Try to detect the source table from a simple query
func (m *queryModel) detectSourceTable() string {
// Simple heuristic: look for "FROM tablename" in the query
queryLower := strings.ToLower(strings.TrimSpace(m.queryInput))
// Look for "FROM table" pattern
fromIndex := strings.Index(queryLower, " from ")
if fromIndex == -1 {
return ""
}
// Extract the part after "FROM "
afterFrom := strings.TrimSpace(queryLower[fromIndex+6:])
// Get the first word (table name) - stop at space, comma, or other SQL keywords
words := strings.Fields(afterFrom)
if len(words) == 0 {
return ""
}
tableName := words[0]
// Remove common SQL keywords that might follow the table name
stopWords := []string{"where", "order", "group", "having", "limit", "join", "inner", "left", "right", "on"}
if slices.Contains(stopWords, tableName) {
return ""
}
// Verify this table actually exists
for _, existingTable := range m.shared.tables {
if strings.ToLower(existingTable) == tableName {
return existingTable
}
}
return ""
}

View File

@@ -1,172 +0,0 @@
package main
import (
"fmt"
"strings"
tea "github.com/charmbracelet/bubbletea"
)
// Row Detail Model
type rowDetailModel struct {
shared *sharedData
rowIndex int
selectedCol int
fromQuery bool // true if came from query results
}
func newRowDetailModel(shared *sharedData, rowIndex int) *rowDetailModel {
return &rowDetailModel{
shared: shared,
rowIndex: rowIndex,
selectedCol: 0,
fromQuery: false, // default to false, will be set by caller if needed
}
}
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:
return m.handleNavigation(msg)
}
return m, nil
}
func (m *rowDetailModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) {
switch msg.String() {
case "esc":
if m.fromQuery {
return m, func() tea.Msg { return returnToQueryMsg{} }
} else {
return m, func() tea.Msg {
return switchToTableDataMsg{tableIndex: m.shared.selectedTable}
}
}
case "enter":
if len(m.shared.filteredData) > 0 && m.rowIndex < len(m.shared.filteredData) &&
m.selectedCol < len(m.shared.columns) {
return m, func() tea.Msg {
return switchToEditCellMsg{rowIndex: m.rowIndex, colIndex: m.selectedCol}
}
}
case "up", "k":
if m.selectedCol > 0 {
m.selectedCol--
}
case "down", "j":
if m.selectedCol < len(m.shared.columns)-1 {
m.selectedCol++
}
case "r":
return m, func() tea.Msg { return refreshDataMsg{} }
}
return m, nil
}
func (m *rowDetailModel) getVisibleRowCount() int {
reservedLines := 9
return max(1, m.shared.height-reservedLines)
}
func (m *rowDetailModel) View() string {
var content strings.Builder
if m.fromQuery {
content.WriteString(titleStyle.Render("Row Detail: Query Result"))
} else {
tableName := m.shared.filteredTables[m.shared.selectedTable]
content.WriteString(titleStyle.Render(fmt.Sprintf("Row Detail: %s", tableName)))
}
content.WriteString("\n\n")
if m.rowIndex >= len(m.shared.filteredData) {
content.WriteString("Invalid row selection")
} else {
row := m.shared.filteredData[m.rowIndex]
// Show as 2-column table: Column | Value
colWidth := max(15, m.shared.width/3)
valueWidth := max(20, m.shared.width-colWidth-5)
// Header
headerRow := fmt.Sprintf("%-*s | %-*s", colWidth, "Column", valueWidth, "Value")
content.WriteString(selectedStyle.Render(headerRow))
content.WriteString("\n")
// Separator
separator := strings.Repeat("-", colWidth) + "-+-" + strings.Repeat("-", valueWidth)
content.WriteString(separator)
content.WriteString("\n")
// Data rows
visibleRows := m.getVisibleRowCount()
displayRows := min(len(m.shared.columns), visibleRows)
for i := range displayRows {
if i >= len(m.shared.columns) || i >= len(row) {
break
}
col := m.shared.columns[i]
val := row[i]
// For long values, show them wrapped on multiple lines
if len(val) > valueWidth {
// First line with column name
firstLine := fmt.Sprintf("%-*s | %-*s",
colWidth, truncateString(col, colWidth),
valueWidth, truncateString(val, valueWidth))
if i == m.selectedCol {
content.WriteString(selectedStyle.Render(firstLine))
} else {
content.WriteString(normalStyle.Render(firstLine))
}
content.WriteString("\n")
// Additional lines for wrapped text (if there's space)
if len(val) > valueWidth && visibleRows > displayRows {
wrappedLines := wrapText(val, valueWidth)
for j, wrappedLine := range wrappedLines[1:] { // Skip first line already shown
if j >= 2 { // Limit to 3 total lines per field
break
}
continuationLine := fmt.Sprintf("%-*s | %-*s",
colWidth, "", valueWidth, wrappedLine)
if i == m.selectedCol {
content.WriteString(selectedStyle.Render(continuationLine))
} else {
content.WriteString(normalStyle.Render(continuationLine))
}
content.WriteString("\n")
}
}
} else {
// Normal single line
dataRow := fmt.Sprintf("%-*s | %-*s",
colWidth, truncateString(col, colWidth),
valueWidth, val)
if i == m.selectedCol {
content.WriteString(selectedStyle.Render(dataRow))
} else {
content.WriteString(normalStyle.Render(dataRow))
}
content.WriteString("\n")
}
}
}
content.WriteString("\n")
content.WriteString(helpStyle.Render("↑/↓: select field • enter: edit • esc: back • ctrl+c: quit"))
return content.String()
}

View File

@@ -1,228 +0,0 @@
package main
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 "esc":
return m, func() tea.Msg { return switchToTableListMsg{} }
case "/":
m.searching = true
m.searchInput = ""
return m, nil
case "enter":
if len(m.shared.filteredData) > 0 {
return m, func() tea.Msg {
return switchToRowDetailMsg{rowIndex: m.selectedRow}
}
}
case "r":
return m, func() tea.Msg { return refreshDataMsg{} }
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.selectedRow = 0
return m, func() tea.Msg { return refreshDataMsg{} }
}
case "right", "l":
maxPage := max(0, (m.shared.totalRows-1)/pageSize)
if m.shared.currentPage < maxPage {
m.shared.currentPage++
m.selectedRow = 0
return m, func() tea.Msg { return refreshDataMsg{} }
}
}
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 {
found := false
for _, cell := range row {
if strings.Contains(strings.ToLower(cell), searchLower) {
found = true
break
}
}
if found {
m.shared.filteredData = append(m.shared.filteredData, row)
}
}
}
if m.selectedRow >= len(m.shared.filteredData) {
m.selectedRow = 0
}
}
func (m *tableDataModel) getVisibleRowCount() int {
reservedLines := 9
if m.searching {
reservedLines += 2
}
return max(1, m.shared.height-reservedLines)
}
func (m *tableDataModel) View() string {
var content strings.Builder
tableName := m.shared.filteredTables[m.shared.selectedTable]
maxPage := max(0, (m.shared.totalRows-1)/pageSize)
content.WriteString(titleStyle.Render(fmt.Sprintf("Table: %s (Page %d/%d)",
tableName, m.shared.currentPage+1, maxPage+1)))
content.WriteString("\n")
if m.searching {
content.WriteString("\nSearch data: " + 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")
}
content.WriteString("\n")
if len(m.shared.filteredData) == 0 {
if m.searchInput != "" {
content.WriteString("No rows match your search")
} else {
content.WriteString("No data in table")
}
} else {
visibleRows := m.getVisibleRowCount()
displayRows := min(len(m.shared.filteredData), visibleRows)
// Create table header
colWidth := 10
if len(m.shared.columns) > 0 && m.shared.width > 0 {
colWidth = max(10, (m.shared.width-len(m.shared.columns)*3)/len(m.shared.columns))
}
var headerRow strings.Builder
for i, col := range m.shared.columns {
if i > 0 {
headerRow.WriteString(" | ")
}
headerRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(col, colWidth)))
}
content.WriteString(selectedStyle.Render(headerRow.String()))
content.WriteString("\n")
// Add separator
var separator strings.Builder
for i := range m.shared.columns {
if i > 0 {
separator.WriteString("-+-")
}
separator.WriteString(strings.Repeat("-", colWidth))
}
content.WriteString(separator.String())
content.WriteString("\n")
// Add data rows with highlighting
for i := range displayRows {
if i >= len(m.shared.filteredData) {
break
}
row := m.shared.filteredData[i]
var dataRow strings.Builder
for j, cell := range row {
if j > 0 {
dataRow.WriteString(" | ")
}
dataRow.WriteString(fmt.Sprintf("%-*s", colWidth, truncateString(cell, colWidth)))
}
if i == m.selectedRow {
content.WriteString(selectedStyle.Render(dataRow.String()))
} else {
content.WriteString(normalStyle.Render(dataRow.String()))
}
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("↑/↓: select row • ←/→: page • /: search • enter: view row • esc: back • r: refresh • ctrl+c: quit"))
}
return content.String()
}