diff --git a/README.md b/README.md index e8ae162..60644bf 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,7 @@ go run main.go ``` 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,10 +79,11 @@ 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**: - - **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation + - **Cursor Movement**: `←/→` arrows, `Ctrl+←/→` for word navigation - **Line Navigation**: `Home`/`Ctrl+A` (start), `End`/`Ctrl+E` (end) - **Deletion**: `Backspace`, `Delete`/`Ctrl+D`, `Ctrl+W` (word), `Ctrl+K` (to end), `Ctrl+U` (to start) - `Enter`: Execute query @@ -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,7 +145,8 @@ 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 -- All changes are committed to the database in real-time \ No newline at end of file +- All changes are committed to the database in real-time diff --git a/cmd/root.go b/cmd/root.go new file mode 100644 index 0000000..c8146de --- /dev/null +++ b/cmd/root.go @@ -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") +} \ No newline at end of file diff --git a/edit_cell.go b/edit_cell.go deleted file mode 100644 index 13ba676..0000000 --- a/edit_cell.go +++ /dev/null @@ -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() -} diff --git a/go.mod b/go.mod index 9e0a99f..db203a9 100644 --- a/go.mod +++ b/go.mod @@ -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 ) diff --git a/go.sum b/go.sum index b15c49b..776263e 100644 --- a/go.sum +++ b/go.sum @@ -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= diff --git a/internal/app/app.go b/internal/app/app.go new file mode 100644 index 0000000..8cad95f --- /dev/null +++ b/internal/app/app.go @@ -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, ¬Null, &defaultValue, &pk); err != nil { + return err + } + s.Columns = append(s.Columns, name) + if pk == 1 { + s.PrimaryKeys = append(s.PrimaryKeys, name) + } + } + + // Get total row count + countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName) + err = s.DB.QueryRow(countQuery).Scan(&s.TotalRows) + if err != nil { + return err + } + + // Get paginated data + offset := s.CurrentPage * PageSize + dataQuery := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableName, PageSize, offset) + + rows, err = s.DB.Query(dataQuery) + if err != nil { + return err + } + defer rows.Close() + + s.TableData = [][]string{} + for rows.Next() { + values := make([]any, len(s.Columns)) + valuePtrs := make([]any, len(s.Columns)) + for i := range values { + valuePtrs[i] = &values[i] + } + + if err := rows.Scan(valuePtrs...); err != nil { + return err + } + + row := make([]string, len(s.Columns)) + for i, val := range values { + if val == nil { + row[i] = "NULL" + } else { + row[i] = fmt.Sprintf("%v", val) + } + } + s.TableData = append(s.TableData, row) + } + + s.FilteredData = make([][]string, len(s.TableData)) + copy(s.FilteredData, s.TableData) + + // 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, ¬Null, &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) + } +} \ No newline at end of file diff --git a/internal/app/edit_cell.go b/internal/app/edit_cell.go new file mode 100644 index 0000000..df7db62 --- /dev/null +++ b/internal/app/edit_cell.go @@ -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 +} \ No newline at end of file diff --git a/internal/app/query.go b/internal/app/query.go new file mode 100644 index 0000000..2289d88 --- /dev/null +++ b/internal/app/query.go @@ -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() +} \ No newline at end of file diff --git a/internal/app/row_detail.go b/internal/app/row_detail.go new file mode 100644 index 0000000..d04e7cc --- /dev/null +++ b/internal/app/row_detail.go @@ -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() +} \ No newline at end of file diff --git a/internal/app/table_data.go b/internal/app/table_data.go new file mode 100644 index 0000000..7b7329a --- /dev/null +++ b/internal/app/table_data.go @@ -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() +} \ No newline at end of file diff --git a/table_list.go b/internal/app/table_list.go similarity index 57% rename from table_list.go rename to internal/app/table_list.go index 50b6059..a972277 100644 --- a/table_list.go +++ b/internal/app/table_list.go @@ -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,30 +169,30 @@ 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() -} +} \ No newline at end of file diff --git a/main.go b/main.go index 1feca38..7e0d9da 100644 --- a/main.go +++ b/main.go @@ -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 ") - 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, ¬Null, &defaultValue, &pk); err != nil { - return err - } - s.columns = append(s.columns, name) - if pk == 1 { - s.primaryKeys = append(s.primaryKeys, name) - } - } - - // Get total row count - countQuery := fmt.Sprintf("SELECT COUNT(*) FROM %s", tableName) - err = s.db.QueryRow(countQuery).Scan(&s.totalRows) - if err != nil { - return err - } - - // Get paginated data - offset := s.currentPage * pageSize - dataQuery := fmt.Sprintf("SELECT * FROM %s LIMIT %d OFFSET %d", tableName, pageSize, offset) - - rows, err = s.db.Query(dataQuery) - if err != nil { - return err - } - defer rows.Close() - - s.tableData = [][]string{} - for rows.Next() { - values := make([]any, len(s.columns)) - valuePtrs := make([]any, len(s.columns)) - for i := range values { - valuePtrs[i] = &values[i] - } - - if err := rows.Scan(valuePtrs...); err != nil { - return err - } - - row := make([]string, len(s.columns)) - for i, val := range values { - if val == nil { - row[i] = "NULL" - } else { - row[i] = fmt.Sprintf("%v", val) - } - } - s.tableData = append(s.tableData, row) - } - - s.filteredData = make([][]string, len(s.tableData)) - copy(s.filteredData, s.tableData) - - // 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, ¬Null, &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) - } -} +} \ No newline at end of file diff --git a/query.go b/query.go deleted file mode 100644 index baa4643..0000000 --- a/query.go +++ /dev/null @@ -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 "" -} diff --git a/row_detail.go b/row_detail.go deleted file mode 100644 index 1a3213e..0000000 --- a/row_detail.go +++ /dev/null @@ -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() -} diff --git a/table_data.go b/table_data.go deleted file mode 100644 index e0471e4..0000000 --- a/table_data.go +++ /dev/null @@ -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() -}