From ac9147ac1426dece33df0dfb4ac233746fab1faa Mon Sep 17 00:00:00 2001 From: Tai Groot Date: Sat, 12 Jul 2025 22:17:27 -0700 Subject: [PATCH] small cleanups, modernize --- .gitignore | 2 +- README.md | 8 +- edit_cell.go | 4 +- go.mod | 16 ++- go.sum | 50 +++++++- main.go | 312 +++++++++++++++++++++++++++++++++++++------------- query.go | 82 +++++++++---- row_detail.go | 47 ++++---- table_data.go | 33 +++--- table_list.go | 6 +- 10 files changed, 404 insertions(+), 156 deletions(-) diff --git a/.gitignore b/.gitignore index 5780e84..4990d9c 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,3 @@ -go-sqlite-tui +teaqlite .crush/ *.db diff --git a/README.md b/README.md index 4ea78a0..e8ae162 100644 --- a/README.md +++ b/README.md @@ -82,8 +82,9 @@ go run main.go sample.db - `Tab`: Switch focus to results (when available) - **Results Focus**: - `↑/↓` or `k/j`: Navigate between result rows - - `Enter`: View selected row in detail modal + - `Enter`: View selected row in detail modal (editable for simple queries) - `Tab`: Switch focus back to query input +- **Note**: Query results from simple single-table queries can be edited; complex queries (JOINs, etc.) are automatically detected and handled safely - Type your SQL query (all keys work as input, no conflicts with navigation) - `Esc`: Return to table list - `q` or `Ctrl+C`: Quit @@ -96,8 +97,9 @@ go run main.go sample.db 4. **Row Highlighting**: Cursor-based row selection with visual highlighting 5. **Data Search**: Search within table data across all columns 6. **Row Detail Modal**: 2-column view showing Column | Value for selected row -7. **Cell Editing**: Live editing of individual cell values with database updates -8. **Readline-style Editing**: Full cursor control with word navigation, line navigation, and advanced deletion +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 diff --git a/edit_cell.go b/edit_cell.go index 5ed3ef2..b6fbaf3 100644 --- a/edit_cell.go +++ b/edit_cell.go @@ -37,7 +37,7 @@ func (m *editCellModel) Init() tea.Cmd { return nil } -func (m *editCellModel) Update(msg tea.Msg) (subModel, tea.Cmd) { +func (m *editCellModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleInput(msg) @@ -45,7 +45,7 @@ func (m *editCellModel) Update(msg tea.Msg) (subModel, tea.Cmd) { return m, nil } -func (m *editCellModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *editCellModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": return m, func() tea.Msg { diff --git a/go.mod b/go.mod index 17f0b1b..9e0a99f 100644 --- a/go.mod +++ b/go.mod @@ -1,18 +1,20 @@ -module github.com/taigrr/go-sqlite-tui +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/mattn/go-sqlite3 v1.14.24 + 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/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/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 @@ -20,8 +22,14 @@ require ( 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/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/sync v0.9.0 // indirect - golang.org/x/sys v0.27.0 // 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 + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect ) diff --git a/go.sum b/go.sum index 13124e7..b15c49b 100644 --- a/go.sum +++ b/go.sum @@ -8,8 +8,14 @@ github.com/charmbracelet/x/ansi v0.4.5 h1:LqK4vwBNaXw2AyGIICa5/29Sbdq58GbGdFngSe github.com/charmbracelet/x/ansi v0.4.5/go.mod h1:dk73KoMTT5AX5BsX0KrqhsTqAnhZZoCBjs7dGWp4Ktw= github.com/charmbracelet/x/term v0.2.1 h1:AQeHeLZ1OqSXhrAWpYUtZyX1T3zVxfpZuEQMIQaGIAQ= github.com/charmbracelet/x/term v0.2.1/go.mod h1:oQ4enTYFV7QN4m0i9mzHrViD7TQKvNEEkHUMCmsxdUg= +github.com/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= github.com/erikgeiser/coninput v0.0.0-20211004153227-1c3628e74d0f/go.mod h1:vw97MGsxSvLiUE2X8qFplwetxpGLQrlU1Q9AUEIzCaM= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +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/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= @@ -18,22 +24,54 @@ github.com/mattn/go-localereader v0.0.1 h1:ygSAOl7ZXTx4RdPYinUpg6W99U8jWvWi9Ye2J github.com/mattn/go-localereader v0.0.1/go.mod h1:8fBrzywKY7BI3czFoHkuzRoWE9C+EiG4R1k4Cjx5p88= github.com/mattn/go-runewidth v0.0.15 h1:UNAjwbU9l54TA3KzvqLGxwWjHmMgBUVhBiTjelZgg3U= github.com/mattn/go-runewidth v0.0.15/go.mod h1:Jdepj2loyihRzMpdS35Xk/zdY8IAYHsh153qUoGf23w= -github.com/mattn/go-sqlite3 v1.14.24 h1:tpSp2G2KyMnnQu99ngJ47EIkWVmliIizyZBfPrBWDRM= -github.com/mattn/go-sqlite3 v1.14.24/go.mod h1:Uh1q+B4BYcTPb+yiD3kU8Ct7aC0hY9fxUwlHK0RXw+Y= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6 h1:ZK8zHtRHOkbHy6Mmr5D264iyp3TiX5OmNcI5cIARiQI= github.com/muesli/ansi v0.0.0-20230316100256-276c6243b2f6/go.mod h1:CJlz5H+gyd6CUWT45Oy4q24RdLyn7Md9Vj2/ldJBSIo= github.com/muesli/cancelreader v0.2.2 h1:3I4Kt4BQjOR54NavqnDogx/MIoWBFa0StPA8ELUXHmA= github.com/muesli/cancelreader v0.2.2/go.mod h1:3XuTXfFS2VjM+HTLZY9Ak0l6eUKfijIfMUZ4EgX0QYo= github.com/muesli/termenv v0.15.2 h1:GohcuySI0QmI3wN8Ok9PtKGkgkFIk7y6Vpb5PvrY+Wo= github.com/muesli/termenv v0.15.2/go.mod h1:Epx+iuz8sNs7mNKhxzH4fWXGNpZwUaJKRS1noLXviQ8= +github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4= +github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +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= -golang.org/x/sync v0.9.0 h1:fEo0HyrW1GIgZdpbhCRO0PkJajUS5H9IFUztCgEo2jQ= -golang.org/x/sync v0.9.0/go.mod h1:Czt+wKu1gCyEFDUtn0jG5QVvpJ6rzVqr5aXyt9drQfk= +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/mod v0.24.0 h1:ZfthKaKaT4NrhGVZHO1/WDTwGES4De8KtWO0SIbNJMU= +golang.org/x/mod v0.24.0/go.mod h1:IXM97Txy2VM4PJ3gI61r1YEk/gAj6zAHN3AdZt6S9Ww= +golang.org/x/sync v0.14.0 h1:woo0S4Yywslg6hp4eUFjTVOyKt0RookbpAHG4c1HmhQ= +golang.org/x/sync v0.14.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.27.0 h1:wBqf8DvsY9Y/2P8gAfPDEYNuS30J4lPHJxXSb/nJZ+s= -golang.org/x/sys v0.27.0/go.mod h1:/VUhepiaJMQUp4+oa/7Zr1D23ma6VTLIYjOOTFZPUcA= +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/tools v0.33.0 h1:4qz2S3zmRxbGIhDIAgjxvFutSvH5EfnsYrRBj0UI0bc= +golang.org/x/tools v0.33.0/go.mod h1:CIJMaWEY88juyUfo7UbgPqbC8rU2OqfAV1h2Qp0oMYI= +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= +modernc.org/ccgo/v4 v4.28.0/go.mod h1:JygV3+9AV6SmPhDasu4JgquwU81XAKLd3OKTUDNOiKE= +modernc.org/fileutil v1.3.3 h1:3qaU+7f7xxTUmvU1pJTZiDLAIoJVdUSSauJNHg9yXoA= +modernc.org/fileutil v1.3.3/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +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/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= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= +modernc.org/sqlite v1.38.0 h1:+4OrfPQ8pxHKuWG4md1JpR/EYAh3Md7TdejuuzE7EUI= +modernc.org/sqlite v1.38.0/go.mod h1:1Bj+yES4SVvBZ4cBOpVZ6QgesMCKpJZDq0nxYzOpmNE= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/main.go b/main.go index 295049d..3119fd5 100644 --- a/main.go +++ b/main.go @@ -5,11 +5,12 @@ import ( "fmt" "log" "os" + "slices" "strings" tea "github.com/charmbracelet/bubbletea" "github.com/charmbracelet/lipgloss" - _ "github.com/mattn/go-sqlite3" + _ "modernc.org/sqlite" // Import SQLite driver ) const ( @@ -18,29 +19,25 @@ const ( // Custom message types type ( - switchToTableListMsg struct{} - switchToTableDataMsg struct{ tableIndex int } - switchToRowDetailMsg struct{ rowIndex int } + 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 } + 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 } ) -// Common interface for all models -type subModel interface { - Update(tea.Msg) (subModel, tea.Cmd) - View() string - Init() tea.Cmd -} - // Main application model type model struct { db *sql.DB - currentView subModel + currentView tea.Model width int height int err error @@ -60,6 +57,32 @@ type sharedData struct { 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 { + log.Fatal(err) + } } func newSharedData(db *sql.DB) *sharedData { @@ -73,7 +96,8 @@ func newSharedData(db *sql.DB) *sharedData { } func (s *sharedData) loadTables() error { - rows, err := s.db.Query("SELECT name FROM sqlite_master WHERE type='table' ORDER BY name") + query := `SELECT name FROM sqlite_master WHERE type='table' ORDER BY name` + rows, err := s.db.Query(query) if err != nil { return err } @@ -98,7 +122,7 @@ func (s *sharedData) loadTableData() error { } 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 { @@ -113,7 +137,7 @@ func (s *sharedData) loadTableData() error { 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 } @@ -133,7 +157,7 @@ func (s *sharedData) loadTableData() error { // 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 @@ -162,9 +186,14 @@ func (s *sharedData) loadTableData() error { } 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 } @@ -172,51 +201,83 @@ func (s *sharedData) updateCell(rowIndex, colIndex int, newValue string) error { if rowIndex >= len(s.filteredData) || colIndex >= len(s.columns) { return fmt.Errorf("invalid row or column index") } - - tableName := s.filteredTables[s.selectedTable] - columnName := s.columns[colIndex] - - // Build WHERE clause using primary keys or all columns if no primary key - var whereClause strings.Builder - var args []any - - if len(s.primaryKeys) > 0 { - for i, pkCol := range s.primaryKeys { - if i > 0 { - whereClause.WriteString(" AND ") - } - pkIndex := -1 - for j, col := range s.columns { - if col == pkCol { - pkIndex = j - break - } - } - if pkIndex >= 0 { - whereClause.WriteString(fmt.Sprintf("%s = ?", pkCol)) - args = append(args, s.filteredData[rowIndex][pkIndex]) + + 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 i, col := range s.columns { + // 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, s.filteredData[rowIndex][i]) + 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...) + + _, 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 @@ -232,10 +293,124 @@ func (s *sharedData) updateCell(rowIndex, colIndex int, newValue string) error { } } } - + 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(). @@ -272,13 +447,13 @@ 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 { @@ -300,11 +475,11 @@ func wrapText(text string, width int) []string { } } } - + if currentLine != "" { lines = append(lines, currentLine) } - + return lines } @@ -454,26 +629,3 @@ func (m model) getSharedData() *sharedData { return newSharedData(m.db) } } - -func main() { - if len(os.Args) < 2 { - fmt.Println("Usage: go-sqlite-tui ") - os.Exit(1) - } - - dbPath := os.Args[1] - if _, err := os.Stat(dbPath); os.IsNotExist(err) { - fmt.Printf("Database file '%s' does not exist\n", dbPath) - os.Exit(1) - } - - m := initialModel(dbPath) - if m.err != nil { - log.Fatal(m.err) - } - - p := tea.NewProgram(m, tea.WithAltScreen()) - if _, err := p.Run(); err != nil { - log.Fatal(err) - } -} \ No newline at end of file diff --git a/query.go b/query.go index eb45f62..518b041 100644 --- a/query.go +++ b/query.go @@ -2,6 +2,7 @@ package main import ( "fmt" + "slices" "strings" tea "github.com/charmbracelet/bubbletea" @@ -30,7 +31,7 @@ func (m *queryModel) Init() tea.Cmd { return nil } -func (m *queryModel) Update(msg tea.Msg) (subModel, tea.Cmd) { +func (m *queryModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleInput(msg) @@ -38,7 +39,7 @@ func (m *queryModel) Update(msg tea.Msg) (subModel, tea.Cmd) { return m, nil } -func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *queryModel) handleInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": return m, func() tea.Msg { return switchToTableListMsg{} } @@ -58,7 +59,7 @@ func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { if m.focusOnInput { // Execute query when input is focused if err := m.executeQuery(); err != nil { - // Handle error - could set an error field + // TODO: Handle error - could set an error field } } else { // View row detail when results are focused @@ -66,6 +67,9 @@ func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { // 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} } @@ -82,7 +86,7 @@ func (m *queryModel) handleInput(msg tea.KeyMsg) (subModel, tea.Cmd) { } } -func (m *queryModel) handleInputControls(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *queryModel) handleInputControls(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { // Cursor movement case "left": @@ -144,7 +148,7 @@ func (m *queryModel) handleInputControls(msg tea.KeyMsg) (subModel, tea.Cmd) { return m, nil } -func (m *queryModel) handleResultsNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *queryModel) handleResultsNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "up", "k": if m.selectedRow > 0 { @@ -179,17 +183,17 @@ 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 } @@ -198,17 +202,17 @@ func (m *queryModel) wordRight(pos int) int { 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 } @@ -276,14 +280,14 @@ func (m *queryModel) View() string { content.WriteString(titleStyle.Render("SQL Query")) content.WriteString("\n\n") - + // 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] @@ -308,17 +312,17 @@ func (m *queryModel) View() string { 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 { @@ -339,7 +343,7 @@ func (m *queryModel) View() string { content.WriteString(separator.String()) content.WriteString("\n") - for i := 0; i < displayRows; i++ { + for i := range displayRows { row := m.results[i] var dataRow strings.Builder for j, cell := range row { @@ -348,7 +352,7 @@ func (m *queryModel) View() string { } 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())) @@ -357,7 +361,7 @@ func (m *queryModel) View() 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") @@ -372,4 +376,42 @@ func (m *queryModel) View() string { } return content.String() -} \ No newline at end of file +} + +// 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 index 359986c..13e6250 100644 --- a/row_detail.go +++ b/row_detail.go @@ -28,7 +28,7 @@ func (m *rowDetailModel) Init() tea.Cmd { return nil } -func (m *rowDetailModel) Update(msg tea.Msg) (subModel, tea.Cmd) { +func (m *rowDetailModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: return m.handleNavigation(msg) @@ -36,7 +36,7 @@ func (m *rowDetailModel) Update(msg tea.Msg) (subModel, tea.Cmd) { return m, nil } -func (m *rowDetailModel) handleNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *rowDetailModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": if m.fromQuery { @@ -48,8 +48,8 @@ func (m *rowDetailModel) handleNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) { } case "enter": - if len(m.shared.filteredData) > 0 && m.rowIndex < len(m.shared.filteredData) && - m.selectedCol < len(m.shared.columns) { + 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} } @@ -79,55 +79,59 @@ func (m *rowDetailModel) getVisibleRowCount() int { func (m *rowDetailModel) View() string { var content strings.Builder - tableName := m.shared.filteredTables[m.shared.selectedTable] - content.WriteString(titleStyle.Render(fmt.Sprintf("Row Detail: %s", tableName))) + 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 := 0; i < displayRows; i++ { + + 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", + 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) @@ -135,7 +139,7 @@ func (m *rowDetailModel) View() string { if j >= 2 { // Limit to 3 total lines per field break } - continuationLine := fmt.Sprintf("%-*s | %-*s", + continuationLine := fmt.Sprintf("%-*s | %-*s", colWidth, "", valueWidth, wrappedLine) if i == m.selectedCol { content.WriteString(selectedStyle.Render(continuationLine)) @@ -147,10 +151,10 @@ func (m *rowDetailModel) View() string { } } else { // Normal single line - dataRow := fmt.Sprintf("%-*s | %-*s", + dataRow := fmt.Sprintf("%-*s | %-*s", colWidth, truncateString(col, colWidth), valueWidth, val) - + if i == m.selectedCol { content.WriteString(selectedStyle.Render(dataRow)) } else { @@ -165,4 +169,5 @@ func (m *rowDetailModel) View() string { content.WriteString(helpStyle.Render("↑/↓: select field • enter: edit • esc: back • q: quit")) return content.String() -} \ No newline at end of file +} + diff --git a/table_data.go b/table_data.go index 516765f..838b9d9 100644 --- a/table_data.go +++ b/table_data.go @@ -9,10 +9,10 @@ import ( // Table Data Model type tableDataModel struct { - shared *sharedData - selectedRow int - searchInput string - searching bool + shared *sharedData + selectedRow int + searchInput string + searching bool } func newTableDataModel(shared *sharedData) *tableDataModel { @@ -26,7 +26,7 @@ func (m *tableDataModel) Init() tea.Cmd { return nil } -func (m *tableDataModel) Update(msg tea.Msg) (subModel, tea.Cmd) { +func (m *tableDataModel) Update(msg tea.Msg) (tea.Model, tea.Cmd) { switch msg := msg.(type) { case tea.KeyMsg: if m.searching { @@ -37,7 +37,7 @@ func (m *tableDataModel) Update(msg tea.Msg) (subModel, tea.Cmd) { return m, nil } -func (m *tableDataModel) handleSearchInput(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *tableDataModel) handleSearchInput(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc", "enter": m.searching = false @@ -56,7 +56,7 @@ func (m *tableDataModel) handleSearchInput(msg tea.KeyMsg) (subModel, tea.Cmd) { return m, nil } -func (m *tableDataModel) handleNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *tableDataModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "esc": return m, func() tea.Msg { return switchToTableListMsg{} } @@ -124,7 +124,7 @@ func (m *tableDataModel) filterData() { } } } - + if m.selectedRow >= len(m.shared.filteredData) { m.selectedRow = 0 } @@ -143,16 +143,16 @@ func (m *tableDataModel) View() string { 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)", + + 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)", + content.WriteString(fmt.Sprintf("\nFiltered by: %s (%d/%d rows)", m.searchInput, len(m.shared.filteredData), len(m.shared.tableData))) content.WriteString("\n") } @@ -167,13 +167,13 @@ func (m *tableDataModel) View() string { } 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 { @@ -196,7 +196,7 @@ func (m *tableDataModel) View() string { content.WriteString("\n") // Add data rows with highlighting - for i := 0; i < displayRows; i++ { + for i := range displayRows { if i >= len(m.shared.filteredData) { break } @@ -225,4 +225,5 @@ func (m *tableDataModel) View() string { } return content.String() -} \ No newline at end of file +} + diff --git a/table_list.go b/table_list.go index 76ad33a..a175c38 100644 --- a/table_list.go +++ b/table_list.go @@ -28,7 +28,7 @@ func (m *tableListModel) Init() tea.Cmd { return nil } -func (m *tableListModel) Update(msg tea.Msg) (subModel, 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) (subModel, tea.Cmd) { return m, nil } -func (m *tableListModel) handleSearchInput(msg tea.KeyMsg) (subModel, 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) (subModel, tea.Cmd) { return m, nil } -func (m *tableListModel) handleNavigation(msg tea.KeyMsg) (subModel, tea.Cmd) { +func (m *tableListModel) handleNavigation(msg tea.KeyMsg) (tea.Model, tea.Cmd) { switch msg.String() { case "/": m.searching = true