mirror of
https://github.com/taigrr/teaqlite.git
synced 2026-04-02 04:59:03 -07:00
small cleanups, modernize
This commit is contained in:
2
.gitignore
vendored
2
.gitignore
vendored
@@ -1,3 +1,3 @@
|
||||
go-sqlite-tui
|
||||
teaqlite
|
||||
.crush/
|
||||
*.db
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
16
go.mod
16
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
|
||||
)
|
||||
|
||||
50
go.sum
50
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=
|
||||
|
||||
268
main.go
268
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 <database.db>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbPath := os.Args[1]
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
fmt.Printf("Database file '%s' does not exist\n", dbPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
m := initialModel(dbPath)
|
||||
if m.err != nil {
|
||||
log.Fatal(m.err)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
@@ -165,6 +189,11 @@ func (s *sharedData) loadTableData() error {
|
||||
|
||||
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
|
||||
}
|
||||
|
||||
@@ -173,50 +202,82 @@ func (s *sharedData) updateCell(rowIndex, colIndex int, newValue string) error {
|
||||
return fmt.Errorf("invalid row or column index")
|
||||
}
|
||||
|
||||
tableName := s.filteredTables[s.selectedTable]
|
||||
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(s.primaryKeys) > 0 {
|
||||
for i, pkCol := range s.primaryKeys {
|
||||
if len(tablePrimaryKeys) > 0 {
|
||||
// Use primary keys for WHERE clause
|
||||
for i, pkCol := range tablePrimaryKeys {
|
||||
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])
|
||||
|
||||
// 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 {
|
||||
for i, col := range s.columns {
|
||||
// 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
|
||||
@@ -236,6 +297,120 @@ 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().
|
||||
@@ -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 <database.db>")
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
dbPath := os.Args[1]
|
||||
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
|
||||
fmt.Printf("Database file '%s' does not exist\n", dbPath)
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
m := initialModel(dbPath)
|
||||
if m.err != nil {
|
||||
log.Fatal(m.err)
|
||||
}
|
||||
|
||||
p := tea.NewProgram(m, tea.WithAltScreen())
|
||||
if _, err := p.Run(); err != nil {
|
||||
log.Fatal(err)
|
||||
}
|
||||
}
|
||||
54
query.go
54
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 {
|
||||
@@ -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 {
|
||||
@@ -373,3 +377,41 @@ func (m *queryModel) View() string {
|
||||
|
||||
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 ""
|
||||
}
|
||||
|
||||
@@ -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 {
|
||||
@@ -49,7 +49,7 @@ 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) {
|
||||
m.selectedCol < len(m.shared.columns) {
|
||||
return m, func() tea.Msg {
|
||||
return switchToEditCellMsg{rowIndex: m.rowIndex, colIndex: m.selectedCol}
|
||||
}
|
||||
@@ -79,8 +79,12 @@ 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) {
|
||||
@@ -106,7 +110,7 @@ func (m *rowDetailModel) View() string {
|
||||
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
|
||||
}
|
||||
@@ -166,3 +170,4 @@ func (m *rowDetailModel) View() string {
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -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{} }
|
||||
@@ -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
|
||||
}
|
||||
@@ -226,3 +226,4 @@ func (m *tableDataModel) View() string {
|
||||
|
||||
return content.String()
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user